Skip to main content
Metadata is a shared mechanism that lets Polar users attach arbitrary key-value pairs to supported resources (customers, orders, subscriptions, products, etc.). It is entirely for the benefit of integrators — Polar itself does not use these values — and is exposed as a plain 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).
Do not use 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 {}).
Why user_metadata and not metadata? metadata is a reserved keyword in SQLAlchemy — it clashes with DeclarativeBase.metadata, the object that holds the table registry. Using user_metadata as the column name avoids this conflict at the ORM level while keeping the public-facing API field name metadata through Pydantic aliasing (see below).

2. Add the Pydantic mixins to the schemas

Use MetadataInputMixin for write schemas (create / update) and MetadataOutputMixin for read schemas:
Pydantic handles the rename transparently:
  • MetadataInputMixin exposes the field as metadata to API consumers and serialises it as user_metadata when writing to the database (serialization_alias="user_metadata").
  • MetadataOutputMixin reads either user_metadata (from the ORM object) or metadata (from a plain dict) and exposes it as metadata in the response (validation_alias=AliasChoices("user_metadata", "metadata")).
API consumers always see metadata; the internal column name user_metadata is never exposed.

3. Support metadata filtering on list endpoints

Add MetadataQuery to your list endpoint and service, then call apply_metadata_clause to filter results:
The query parameter uses the 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:

Pydantic aliasing

This means callers always use 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 OpenAPI parameter 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:
Each value can be a scalar (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:
The descriptor sets 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.