metadata field in every API request and response.
When to use this concept
Add metadata support to a model whenever:- The resource is user-facing and exposed through the public API.
- Users are likely to need to cross-reference Polar records with their own systems (e.g. storing an internal record ID, a feature flag, or a campaign tag).
user_metadata to store Polar-internal data; use dedicated columns for that.
How to use this concept
1. Add the mixin to the SQLAlchemy model
MetadataMixin adds a single user_metadata column of type JSONB (non-nullable, defaults to {}).
Whyuser_metadataand notmetadata?metadatais a reserved keyword in SQLAlchemy — it clashes withDeclarativeBase.metadata, the object that holds the table registry. Usinguser_metadataas the column name avoids this conflict at the ORM level while keeping the public-facing API field namemetadatathrough Pydantic aliasing (see below).
2. Add the Pydantic mixins to the schemas
UseMetadataInputMixin for write schemas (create / update) and MetadataOutputMixin for read schemas:
MetadataInputMixinexposes the field asmetadatato API consumers and serialises it asuser_metadatawhen writing to the database (serialization_alias="user_metadata").MetadataOutputMixinreads eitheruser_metadata(from the ORM object) ormetadata(from a plain dict) and exposes it asmetadatain the response (validation_alias=AliasChoices("user_metadata", "metadata")).
metadata; the internal column name user_metadata is never exposed.
3. Support metadata filtering on list endpoints
AddMetadataQuery to your list endpoint and service, then call apply_metadata_clause to filter results:
deepObject style: ?metadata[key]=value. Multiple values for the same key are OR-ed; multiple keys are AND-ed.
How it works
Storage
MetadataMixin maps to a PostgreSQL JSONB column:
Validation constraints
MetadataField (used by MetadataInputMixin) enforces the following limits:
| Constraint | Limit |
|---|---|
| Maximum keys per object | 50 |
| Maximum key length | 40 characters |
| Maximum string value length | 500 characters |
| Allowed value types | str, int, float, bool |
Pydantic aliasing
metadata, while internally the ORM column is always accessed as user_metadata.
Filtering
_get_metadata_query parses deepObject-style query parameters (e.g. metadata[key]=value) from the raw request. get_metadata_clause then translates the result into a SQLAlchemy WHERE clause that queries the JSONB column:
Why the OpenAPI schema is injected manually
FastAPI cannot automatically generate a correct OpenAPIparameter object for deepObject-style query parameters — it has no built-in concept for them. Two functions work together to paper over this gap:
add_metadata_query_schema is called once, at application startup, inside the custom openapi() function in polar/openapi.py. It injects a reusable MetadataQuery component into the global components/schemas section of the OpenAPI document:
string, integer, boolean) or an array of scalars — the array form is used when the same key appears multiple times in the query string (e.g. ?metadata[tag]=a&metadata[tag]=b).
get_metadata_query_openapi_schema returns the parameter descriptor that references that component schema. It is passed to individual route definitions via openapi_extra:
style: deepObject and $ref: "#/components/schemas/MetadataQuery", which tells OpenAPI-aware tools (SDK generators, documentation renderers) to expect bracket-notation keys (metadata[key]). Because FastAPI never sees this parameter declaration, it does not try to parse it itself — the actual parsing is handled by _get_metadata_query via a raw Request dependency.
