Step-by-Step Guide to SQLAlchemy 2.0 Type Annotations

Replace every Column(Integer) declaration with Mapped[int] = mapped_column() — that single change is the core of SQLAlchemy 2.0's type annotation migration, and it gives you full static analysis coverage across your entire data layer.

This guide walks through each step of adopting Mapped[] and mapped_column(), from configuring your declarative base through async session integration. It is a companion to the broader Migrating Legacy 1.4 Code to 2.0 Syntax cluster, which covers the full scope of changes including query API rewrites, session lifecycle updates, and tooling for automated codemods.

Quick Answer (Direct Syntax Replacement)

The before/after is mechanical once you see it side by side. Every legacy Column call maps to a mapped_column() call, and every attribute gets a Mapped[T] annotation.

Legacy SQLAlchemy 1.4 style:

from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy.ext.declarative import declarative_base

Base = declarative_base()


class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True)
    username = Column(String(50), nullable=False)
    email = Column(String(100), nullable=True)
    tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False)

Modern SQLAlchemy 2.0 style:

from __future__ import annotations

from typing import ClassVar

from sqlalchemy import String, ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


class User(Base):
    __tablename__: ClassVar[str] = "users"

    id: Mapped[int] = mapped_column(primary_key=True)
    username: Mapped[str] = mapped_column(String(50))
    email: Mapped[str | None] = mapped_column(String(100))
    tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"))

Key mechanical changes: declarative_base() becomes class Base(DeclarativeBase), Column is removed, mapped_column() handles all column-level keyword arguments, and nullability is encoded in the Mapped type itself — Mapped[str] is non-nullable, Mapped[str | None] is nullable. You no longer need nullable=True in most cases because the type annotation drives that inference.

Execution Context and Async Workflow Integration

Why the annotation system works

Mapped[T] is a generic descriptor that SQLAlchemy's metaclass intercepts at class creation time. When you declare id: Mapped[int] = mapped_column(primary_key=True), the ORM introspects the Mapped generic parameter at import time, derives the column type (Integer), derives nullability (False because int not int | None), and registers the column in the mapper. This means the Python type system and the SQLAlchemy mapper are working from the same single source of truth rather than being maintained in parallel.

Static analyzers (mypy, pyright, pylance) understand Mapped[T] natively because SQLAlchemy 2.0 ships first-party PEP 561 compliant stubs. No third-party sqlalchemy2-stubs package is needed — installing it alongside 2.0 creates conflicting symbol definitions and must be removed.

Async variant with asyncpg

The annotation system is fully compatible with async sessions. The Mapped[] declarations on your models are runtime metadata; the async engine only affects how connections are acquired and how SQL is sent. Queries using the modern select() construct preserve generic type parameters through the result pipeline.

from __future__ import annotations

from typing import Sequence

from sqlalchemy import select, String, ForeignKey
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship


class Base(DeclarativeBase):
    pass


class Tenant(Base):
    __tablename__ = "tenants"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(100))
    users: Mapped[list[User]] = relationship(back_populates="tenant")


class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True)
    username: Mapped[str] = mapped_column(String(50))
    email: Mapped[str | None] = mapped_column(String(100))
    tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"))
    tenant: Mapped[Tenant] = relationship(back_populates="users")


engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db")
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)


async def get_users_for_tenant(tenant_id: int) -> Sequence[User]:
    async with AsyncSessionLocal() as session:
        result = await session.execute(
            select(User).where(User.tenant_id == tenant_id)
        )
        return result.scalars().all()
        # Return type is inferred as Sequence[User] — no cast() needed

The critical point for async workflows is expire_on_commit=False. In synchronous SQLAlchemy, accessing an expired attribute after a commit triggers a lazy load transparently. In async contexts, that lazy load would raise MissingGreenlet because there is no active event loop context for the implicit IO. Setting expire_on_commit=False means attributes stay populated after commit, and you rely on explicit await session.refresh(obj) when you need fresh data. The Mapped[] annotation system does not change this behavior — it is a property of the session configuration, not the column declarations.

For relationship loading in async contexts, use selectin loading to avoid the lazy-load trap entirely. Declare relationship(..., lazy="selectin") or pass options=[selectinload(User.tenant)] at query time. This pairs cleanly with Mapped[] relationship annotations and preserves full type inference through the loaded objects.

Resolving Warnings, Errors, and Common Mistakes

The following table covers the exact strings you will see in terminal output or mypy reports, with root causes and fixes ready to apply in production code.

Exact warning / error stringRoot causeProduction fix
SAWarning: Class User will not make use of SQL typing information as provided in the definitionColumn() used instead of mapped_column() on a class that inherits DeclarativeBaseReplace every Column(...) with mapped_column(...) on that model
mypy: Incompatible types in assignment (expression has type "Column[int]", variable has type "Mapped[int]")Legacy Column assigned to a Mapped[T]-annotated attributeUse mapped_column()Mapped[T] expects a MappedColumn descriptor, not Column
RemovedIn20Warning: The Query.get() method is considered legacyCalling session.query(User).get(pk)Replace with await session.get(User, pk)
sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been calledLazy relationship access outside an async context (e.g. after session close or in a sync function)Set lazy="selectin" on the relationship or use selectinload() at query time; set expire_on_commit=False on the session maker
NameError: name 'Order' is not defined at class body evaluationForward reference to a model defined later in the file, without from __future__ import annotationsAdd from __future__ import annotations at the top of every models file; this defers all annotation evaluation to string form
sqlalchemy.exc.ArgumentError: Mapper mapped class User has no property 'orders'relationship() declared with Mapped[list[Order]] but back_populates target name does not match the attribute on OrderVerify that back_populates="user" on the Order side points to an attribute named exactly user on User
mypy: Need type annotation for "orders" (hint: "orders: List[<type>] = ...")relationship() return assigned without Mapped[list[T]] annotationAnnotate the attribute: orders: Mapped[list[Order]] = relationship(...)
LegacyAPIWarning: The declarative_base() function is now available as sqlalchemy.orm.DeclarativeBaseStill importing declarative_base from sqlalchemy.ext.declarativeChange base class to class Base(DeclarativeBase): pass

Advanced Type Annotation Optimization

Using MappedAsDataclass for zero-boilerplate model construction

One non-obvious 2.0 feature is MappedAsDataclass, which combines the Mapped[] annotation system with Python's dataclasses machinery to auto-generate __init__, __repr__, and __eq__ based solely on your column annotations. This eliminates hand-written constructors that drift out of sync with the schema.

from __future__ import annotations

from dataclasses import field
from datetime import datetime
from typing import ClassVar

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


class Base(MappedAsDataclass, DeclarativeBase):
    pass


class Invoice(Base):
    __tablename__: ClassVar[str] = "invoices"

    # init=False fields are excluded from __init__ — the DB populates them
    id: Mapped[int] = mapped_column(primary_key=True, init=False)
    created_at: Mapped[datetime] = mapped_column(
        server_default=func.now(), init=False
    )

    # Required fields come after optional ones to satisfy dataclass ordering
    tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"))
    amount_cents: Mapped[int] = mapped_column()
    description: Mapped[str | None] = mapped_column(String(255), default=None)
    line_items: Mapped[list[LineItem]] = field(default_factory=list, repr=False)


class LineItem(Base):
    __tablename__: ClassVar[str] = "line_items"

    id: Mapped[int] = mapped_column(primary_key=True, init=False)
    invoice_id: Mapped[int] = mapped_column(ForeignKey("invoices.id"))
    product_name: Mapped[str] = mapped_column(String(100))
    quantity: Mapped[int] = mapped_column()
    unit_price_cents: Mapped[int] = mapped_column()

With this setup, creating an invoice is just Invoice(tenant_id=1, amount_cents=4999) — no __init__ to maintain. The init=False annotation on id and created_at tells the dataclass machinery to exclude those from the constructor signature while still mapping them as ORM columns. The field(default_factory=list, repr=False) on the relationship keeps the dataclass valid (relationships are not simple types) while suppressing the relationship from __repr__ output, which avoids triggering lazy loads during debugging.

The key constraint: MappedAsDataclass requires that fields with defaults appear after fields without defaults, following standard dataclass rules. If you have a non-nullable column without a default or server_default, it must come before any column that has a default. Violating this order raises TypeError: non-default argument follows default argument at class definition time, not at runtime — so you catch it immediately.

Centralizing column types with Annotated and type_annotation_map

The repetition of passing String(50), String(255), or Numeric(12, 2) at every call site is the one piece of boilerplate the bare Mapped[] system does not remove. SQLAlchemy 2.0 solves this with two complementary mechanisms: PEP 593 Annotated aliases for reusable column "shapes", and a registry.type_annotation_map for project-wide defaults that map a plain Python type to a specific SQL type.

from __future__ import annotations

from decimal import Decimal
from typing import Annotated

from sqlalchemy import Numeric, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, registry


# Reusable column shapes — declare the SQL type once, use the alias everywhere
str50 = Annotated[str, mapped_column(String(50))]
money = Annotated[Decimal, mapped_column(Numeric(12, 2))]


class Base(DeclarativeBase):
    # Project-wide default: every plain `Mapped[str]` becomes VARCHAR(255)
    registry = registry(type_annotation_map={str: String(255)})


class Product(Base):
    __tablename__ = "products"

    id: Mapped[int] = mapped_column(primary_key=True)
    sku: Mapped[str50] = mapped_column(unique=True)  # VARCHAR(50) via alias
    name: Mapped[str]                                # VARCHAR(255) via map
    list_price: Mapped[money]                        # NUMERIC(12, 2) via alias

Two things make this powerful. First, Mapped[str50] resolves the Annotated metadata to the pre-built mapped_column(String(50)), so the column type and the Python type travel together as a single named unit — refactoring str50 to String(64) updates every model at once. Second, name: Mapped[str] needs no mapped_column() call at all because the type_annotation_map supplies the SQL type for bare str. This is the recommended pattern for large schemas: define your common types once, and let annotations stay declarative and noise-free. Static analyzers still see Product.name as str and Product.list_price as Decimal, so type inference is unaffected.

Frequently Asked Questions

Can I migrate one model at a time, or does the entire codebase have to switch at once? You can migrate incrementally. SQLAlchemy 2.0 supports mixed bases — a model using class Base(DeclarativeBase) with Mapped[] annotations can coexist in the same application with a model on the legacy declarative_base() base, as long as they use separate base classes and are not in the same inheritance hierarchy. Migrate leaf models first (those with no subclasses), then work up toward shared base mixins. The legacy 1.4 to 2.0 codemod checklist provides a model-by-model tracking approach.

Do I still need nullable=True in mapped_column() when I use Mapped[str | None]? No. When SQLAlchemy sees Mapped[str | None], it sets nullable=True on the underlying column automatically. Passing nullable=True explicitly is redundant but harmless. However, explicitly passing nullable=False on a Mapped[str | None] column creates a contradiction — the column will be non-nullable at the database level while the Python type says it can be None. SQLAlchemy will emit a warning and the database constraint takes precedence, so inserts of None will fail.

How do I handle custom types (e.g., UUID, JSONB, Enum) with Mapped[]? Pass the SQLAlchemy type to mapped_column() as you always did, and annotate with the corresponding Python type in Mapped[]. For UUID: id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True). For JSONB: metadata: Mapped[dict] = mapped_column(JSONB). For Python enums: status: Mapped[OrderStatus] = mapped_column(SAEnum(OrderStatus)). SQLAlchemy's type coercion handles the Python-to-database round-trip; the Mapped[] parameter is purely for static analysis and does not affect how the value is serialized.

Why does mypy still report errors even after installing the SQLAlchemy mypy plugin? The most common cause is that the plugin is configured for the project but mypy is resolving to a different Python environment — for example, a global mypy installation pointing to a virtualenv without SQLAlchemy, or a pyproject.toml plugin entry that does not match the installed package name. Verify with mypy --version and pip show sqlalchemy that both come from the same environment. Under [tool.mypy] in pyproject.toml the entry is plugins = ["sqlalchemy.ext.mypy.plugin"] (a single plugins key, not a sub-table). Note also that the mypy plugin is largely unnecessary on 2.0 — the first-party stubs cover annotated models directly, and the plugin mainly helps legacy Column-style declarations during migration. Also confirm you have removed sqlalchemy2-stubs entirely — its presence causes symbol conflicts that the plugin cannot resolve cleanly.