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.
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.
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.
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 fromtype_annotation_mapand with no built-in mapping. Add it to the map, or pass the type explicitly tomapped_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 definedat class-creation time — a forward reference withoutfrom __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]andOptional[str]are the same declaration. A migration that appears to make a column nullable for no reason is usually an annotation that gained an| Noneto satisfy a type checker, where the real fix was elsewhere. RemovedIn20Warningon a mixed model. Old-styleColumn()andmapped_column()may live in one class, but onlymapped_column()participates in annotation resolution — an annotated attribute assigned a plainColumn()ignores the annotation entirely and takes its type from theColumn.- A
TypeDecoratorwithoutcache_ok. EmitsSAWarning: TypeDecorator ... will not produce a cache keyand 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 anArgumentErrorabout 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.
Related
- Mastering SQLAlchemy 2.0 Core and ORM Architecture — The parent guide covering the unified execution model that annotated declarative sits on top of.
- Using mapped_column instead of Column in SQLAlchemy 2.0 — The mechanical conversion, attribute by attribute, and what changes in the generated DDL.
- Typing relationships with Mappedlist... in SQLAlchemy — How the annotation picks the collection class and what Optional does to a join.
- Writing a TypeDecorator for encrypted columns — A complete two-way type with key rotation, and why cache_ok matters.
- Generating model constructors with MappedAsDataclass — Dataclass mapping, field ordering, and the interaction with relationship defaults.
- Step-by-step guide to SQLAlchemy 2.0 type annotations — The migration-focused walkthrough for converting a legacy declarative base.