Typed Declarative Models and Mapped Annotations

In SQLAlchemy 2.0 a model attribute's Python annotation is the authoritative declaration: Mapped[str] states the Python type and that the column is NOT NULL, Mapped[str | None] makes it nullable, and mapped_column() supplies only what the annotation cannot express. That single change removes the oldest class of ORM bug — a schema and a type checker that disagree — and it is the foundation the rest of the modern API is built on. This page sits under mastering SQLAlchemy 2.0 Core and ORM architecture; start there for the wider Core-versus-ORM picture, then return here for how the declarative layer actually resolves a type.

Three inputs, one column The declaration email: Mapped[str | None] = mapped_column(index=True) is assembled from three sources. The annotation Mapped[str | None] contributes the Python type, str, and the nullability, because the Optional wrapper is what makes the column nullable. The registry type_annotation_map turns str into a concrete SQL type such as String(255), once, for the whole project. mapped_column contributes only what the other two cannot express: index, primary key, unique, server default, foreign key. Nothing is stated twice, so the schema and the type checker cannot drift apart. from the annotation Mapped[str] → Python type str, NOT NULL Mapped[str | None] → nullable this is what your editor and mypy read from the registry and mapped_column type_annotation_map: str → String(255) mapped_column(index=True, unique=True) only what the annotation cannot express Because nullability comes from the annotation rather than a separate keyword, a column cannot be NOT NULL in the database while its attribute is typed Optional in Python.

Concept & Execution Model

A declarative class is processed once, at class-creation time, by the registry attached to your DeclarativeBase. For each attribute the registry looks at the annotation, strips the Mapped[...] wrapper, notes whether the inner type is Optional, and then asks: what SQL type corresponds to this Python type? Everything interesting happens in that last question.

from __future__ import annotations

import datetime
import decimal
from typing import Annotated

from sqlalchemy import ForeignKey, Numeric, String, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship

# A project vocabulary: each alias carries the SQL type it means.
str50 = Annotated[str, mapped_column(String(50))]
money = Annotated[decimal.Decimal, mapped_column(Numeric(12, 2))]


class Base(DeclarativeBase):
    # Project-wide rules, stated once. Every bare `str` becomes String(255), every bare
    # Decimal becomes Numeric(12, 2), every datetime becomes a timezone-aware timestamp.
    type_annotation_map = {
        str: String(255),
        decimal.Decimal: Numeric(12, 2),
        datetime.datetime: sa_timestamp := __import__("sqlalchemy").DateTime(timezone=True),
    }


class Customer(Base):
    __tablename__ = "customers"

    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str50] = mapped_column(unique=True, index=True)
    display_name: Mapped[str | None]
    created_at: Mapped[datetime.datetime] = mapped_column(server_default=func.now())

    orders: Mapped[list[Order]] = relationship(back_populates="customer")


class Order(Base):
    __tablename__ = "orders"

    id: Mapped[int] = mapped_column(primary_key=True)
    customer_id: Mapped[int] = mapped_column(ForeignKey("customers.id"), index=True)
    total: Mapped[money]
    note: Mapped[str | None]

    customer: Mapped[Customer] = relationship(back_populates="orders")

Three things in that listing are worth stating plainly. display_name and note have no mapped_column() call at all — the annotation alone is a complete declaration. total: Mapped[money] inherits Numeric(12, 2) from the alias without repeating it. And orders: Mapped[list[Order]] needs no collection_class, because list is right there in the annotation.

The from __future__ import annotations line at the top matters more than it looks. It makes every annotation a string, which is what lets Order be referenced inside Customer before Order exists. Without it, forward references must be quoted individually.

How an annotation is resolved to a SQL type Resolution order, highest precedence first. An Annotated alias that carries its own type instance wins outright, which is what makes a project vocabulary such as str50 or money authoritative. Failing that, the registry type_annotation_map is consulted for the bare Python type, which is where project-wide rules live. Failing that, an explicit type passed positionally to mapped_column is used, which is the escape hatch for a single column that differs. Only if none of those match does SQLAlchemy fall back to its built-in mapping, and for an unregistered type such as Decimal or a custom enum that fallback is what produces an unexpected column type in the generated DDL. Annotated alias with a type instance str50 = Annotated[str, 50] highest precedence registry type_annotation_map project-wide rules the right place for defaults explicit type on mapped_column mapped_column(Numeric(12, 2)) the per-column escape hatch SQLAlchemy’s built-in default the source of surprising DDL An unregistered Decimal falls through to the built-in default and produces a NUMERIC with no precision, which PostgreSQL accepts and then stores at arbitrary scale.

Query Construction & Async Execution Patterns

Typed models change what a query returns as far as a type checker is concerned, and that is most of their day-to-day value. session.execute() returns rows; scalars() unwraps them to entities; and with typed models the checker follows all of it.

# Async — the return types are inferred end to end
import asyncio

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

engine = create_async_engine("postgresql+asyncpg://app:secret@db.internal/orders")
Session = async_sessionmaker(engine, expire_on_commit=False)


async def recent_customers(limit: int) -> list[Customer]:
    async with Session() as session:
        stmt = (
            select(Customer)
            .where(Customer.display_name.is_not(None))
            .order_by(Customer.created_at.desc())
            .limit(limit)
        )
        result = await session.execute(stmt)
        return list(result.scalars())          # checker sees list[Customer]


async def customer_totals() -> list[tuple[str, decimal.Decimal]]:
    async with Session() as session:
        stmt = (
            select(Customer.email, func.sum(Order.total))
            .join(Order, Order.customer_id == Customer.id)
            .group_by(Customer.email)
        )
        result = await session.execute(stmt)
        return list(result.tuples())           # checker sees list[tuple[str, Decimal]]
# Sync — identical construction, different execution
from sqlalchemy.orm import Session as SyncSession


def recent_customers_sync(session: SyncSession, limit: int) -> list[Customer]:
    stmt = select(Customer).order_by(Customer.created_at.desc()).limit(limit)
    return list(session.execute(stmt).scalars())

The statement in both variants is byte-for-byte the same object; only the execution differs. That separation — described in full under Core vs ORM architecture decisions — is why a typed statement can be built in a shared module and executed by both a synchronous worker and an async API.

result.tuples() is worth adopting deliberately. Without it, a multi-column select() gives the checker Row[Any] and every downstream unpacking is unchecked; with it, the element types come through.

State Management & Session Boundaries

Annotations describe the shape of an instance, not whether its attributes are currently loaded. Mapped[list[Order]] promises a list of orders when the attribute is populated; it says nothing about whether accessing it will emit SQL. Under async that distinction is the difference between working code and MissingGreenlet, which the AsyncSession lifecycle covers in depth.

Two habits keep the two in step. First, declare loader intent at the query, not at the attribute: an options(selectinload(Customer.orders)) on the statement is visible where the decision belongs. Second, configure lazy="raise_on_sql" on relationships that must never load implicitly:

class Customer(Base):
    __tablename__ = "customers"

    id: Mapped[int] = mapped_column(primary_key=True)
    # Accessing .orders without an explicit loader option now raises instead of
    # silently emitting a query — or, under async, instead of raising MissingGreenlet
    # from a confusing frame several layers away.
    orders: Mapped[list[Order]] = relationship(
        back_populates="customer", lazy="raise_on_sql"
    )

The error you get from raise_on_sql names the attribute and the class at the access site. The error you get without it, under async, is raised from inside the greenlet machinery and takes considerably longer to trace back to a missing loader option.

Four annotation shapes and what each declares Four cases. Mapped[int] declares a NOT NULL column of the registry type for int. Mapped[str | None] declares the same column as nullable, with no keyword needed. Mapped[list[Item]] declares a one-to-many relationship whose collection class is a list, chosen from the annotation itself rather than a collection_class argument. Mapped[Customer] declares a many-to-one relationship, and because it is not Optional, SQLAlchemy treats the foreign key as required, which is what makes the join inner rather than outer when a loader resolves it. Mapped[int] NOT NULL column type from the registry Mapped[str | None] nullable column no keyword required Mapped[list[Item]] one-to-many list collection, chosen by the annotation Mapped[Customer] many-to-one not Optional → the join is inner The last row is the one that surprises people: making a many-to-one relationship Optional or not changes the join a loader emits, not merely what a type checker will accept.

Advanced Type Extensions: TypeDecorator and Custom Vocabularies

The registry map handles types SQLAlchemy already knows. For a type it does not — an encrypted string, a domain-specific identifier, a value object — a TypeDecorator supplies the conversion in both directions and then registers like any other type.

from __future__ import annotations

import json
from typing import Any

from sqlalchemy import Dialect, String, TypeDecorator


class CanonicalEmail(TypeDecorator[str]):
    """Store e-mail addresses lower-cased and stripped, always."""

    impl = String(255)
    cache_ok = True

    def process_bind_param(self, value: str | None, dialect: Dialect) -> str | None:
        return value.strip().lower() if value is not None else None

    def process_result_value(self, value: str | None, dialect: Dialect) -> str | None:
        return value


class TenantId(TypeDecorator[str]):
    impl = String(40)
    cache_ok = True

    def process_bind_param(self, value: str | None, dialect: Dialect) -> str | None:
        if value is not None and not value.startswith("tnt_"):
            raise ValueError(f"not a tenant id: {value!r}")
        return value

    def process_result_value(self, value: str | None, dialect: Dialect) -> str | None:
        return value

cache_ok = True is not optional in practice. It tells SQLAlchemy the decorator produces the same SQL for the same inputs, which lets statements using it be cached; leaving it unset emits a warning on every compilation and quietly disables the statement cache for those queries — a large, silent performance cost.

Registering the decorator makes it the project default for that annotation:

Email = Annotated[str, CanonicalEmail()]
Tenant = Annotated[str, TenantId()]


class Customer(Base):
    __tablename__ = "customers"

    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[Email] = mapped_column(unique=True)
    tenant: Mapped[Tenant] = mapped_column(index=True)

Every Mapped[Email] anywhere in the project now normalises on write, without a single call site remembering to. This composes with the multi-tenant patterns in dynamic schema and multi-tenant routing, where a validated tenant identifier is exactly the kind of value worth enforcing at the type layer rather than at each boundary.

Hybrid Architectures & Migration Strategies

A large legacy codebase does not convert to annotated declarative in one commit, and it does not need to. The imperative and annotated styles coexist inside one registry, so migration proceeds class by class.

# Legacy — still valid, still works alongside annotated classes
class LegacyInvoice(Base):
    __tablename__ = "invoices"

    id = mapped_column(Integer, primary_key=True)
    reference = mapped_column(String(64), nullable=False, unique=True)
    amount = mapped_column(Numeric(12, 2), nullable=False)
# Converted — same table, same DDL, now typed
class Invoice(Base):
    __tablename__ = "invoices"

    id: Mapped[int] = mapped_column(primary_key=True)
    reference: Mapped[str] = mapped_column(String(64), unique=True)
    amount: Mapped[money]

Converting a class should produce no migration. That is the test worth running: convert, then run alembic revision --autogenerate and confirm the generated script is empty. A non-empty diff means the annotation says something different from what the table actually holds — most often that a column you believed was NOT NULL is nullable in the database, or that an unregistered Decimal resolved to a NUMERIC with no precision. The autogenerate review workflow is the right place to catch that, before the conversion ships.

Order the work by risk rather than by directory: convert the classes with the most call sites first, because those get the most benefit from checking, and leave rarely-touched classes until last.

Production Pitfalls & Anti-Patterns

  • sqlalchemy.exc.ArgumentError: Could not locate SQLAlchemy Core type for Python type <class 'decimal.Decimal'> — the annotation names a Python type absent from type_annotation_map and with no built-in mapping. Add it to the map, or pass the type explicitly to mapped_column(). Adding it to the map is nearly always right: one entry fixes every column of that type in the project.
  • NameError: name 'Order' is not defined at class-creation time — a forward reference without from __future__ import annotations. Add the import at the top of every model module; quoting individual references works but is easy to apply inconsistently.
  • A column silently becomes nullable. Mapped[str | None] and Optional[str] are the same declaration. A migration that appears to make a column nullable for no reason is usually an annotation that gained an | None to satisfy a type checker, where the real fix was elsewhere.
  • RemovedIn20Warning on a mixed model. Old-style Column() and mapped_column() may live in one class, but only mapped_column() participates in annotation resolution — an annotated attribute assigned a plain Column() ignores the annotation entirely and takes its type from the Column.
  • A TypeDecorator without cache_ok. Emits SAWarning: TypeDecorator ... will not produce a cache key and disables statement caching for every query touching that column. The cost is invisible in a benchmark of a single query and substantial under load.
  • Mapped[list[Order]] on a many-to-one. The annotation, not the foreign key, decides the collection: annotating a scalar relationship as a list produces a relationship that tries to build a collection and fails at configuration time with an ArgumentError about a mismatched direction.

Frequently Asked Questions

Do I have to annotate every column, or can I mix styles within a class?

You can mix, and during a migration you generally will. The rule is that a given attribute is either annotated or not: if it carries a Mapped[...] annotation, that annotation drives type and nullability, and any conflicting nullable= keyword is a second source of truth waiting to drift. Within one class, an annotated attribute and a legacy mapped_column(Integer, ...) attribute coexist without any special handling.

What is the practical difference between type_annotation_map and an Annotated alias?

The map answers "what does a bare str mean in this project"; an alias answers "what does this particular kind of str mean". Use the map for the default every column of that Python type should get, and an alias whenever a column has a meaning beyond its Python type — a 50-character name, a currency amount, an encrypted field. Aliases also read better at the point of use: Mapped[money] says more than Mapped[Decimal].

Does MappedAsDataclass work with async?

Yes — it is purely a class-construction feature and does not touch execution at all. It generates __init__, __repr__ and __eq__ from the same annotations, which removes the hand-written constructor most models carry. The one caveat is ordering: dataclass fields without defaults must precede fields with them, so adding a defaulted column in the middle of a class can produce a TypeError at import time until the attributes are reordered.

Will annotating my models change the generated schema?

It should not, and that is worth verifying rather than assuming. Convert a class, run autogenerate, and confirm the migration is empty. Where a diff does appear it is genuine information: the annotation and the live table disagree, and one of them is wrong.

Do type annotations slow anything down at runtime?

No. Resolution happens once per class at import time, not per query or per instance. from __future__ import annotations makes annotations strings, so they are not even evaluated as expressions until the registry asks for them.