Step-by-Step Guide to SQLAlchemy 2.0 Type Annotations
Direct Answer: Core 2.0 Annotation Syntax
SQLAlchemy 2.0 replaces legacy Column declarations with the Mapped[T] and mapped_column() pattern. This enables strict static type checking, aligns with modern Python typing standards, and satisfies both the runtime ORM and static analyzers. For foundational architectural context before implementation, review Mastering SQLAlchemy 2.0 Core and ORM Architecture. Declare attributes as Mapped[str] = mapped_column(String(50)) to enforce type safety across your data layer.
Step 1: Configure Declarative Base with registry
Initialize DeclarativeBase using the modern registry() pattern. Explicitly type __tablename__ as ClassVar[str] to ensure strict typing compatibility. This setup is mandatory before applying column-level annotations and prevents metaclass resolution errors during model instantiation.
Step 2: Annotate Scalar Columns with Mapped
Replace Column(Integer) with Mapped[int] = mapped_column(). Use Python 3.10+ union syntax (T | None) for nullable fields. Explicitly type primary keys as Mapped[int] = mapped_column(primary_key=True). Never mix Column and mapped_column in the same model; doing so triggers mypy assignment errors due to mismatched generic parameters and causes runtime attribute resolution failures.
from typing import ClassVar
from sqlalchemy import String
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)
name: Mapped[str] = mapped_column(String(50))
email: Mapped[str | None] = mapped_column(String(100), nullable=True)
Step 3: Type Relationships and Collections
Use Mapped[List[T]] for one-to-many and Mapped[T] for many-to-one relationships. Always apply relationship() with explicit back_populates to maintain bidirectional type inference. When transitioning older projects, consult Migrating Legacy 1.4 Code to 2.0 Syntax to resolve deprecated column_property and backref patterns that break modern type inference.
from typing import List
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, relationship
class User(Base):
__tablename__: ClassVar[str] = "users"
id: Mapped[int] = mapped_column(primary_key=True)
posts: Mapped[List["Post"]] = relationship(back_populates="author")
class Post(Base):
__tablename__: ClassVar[str] = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
author_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
author: Mapped["User"] = relationship(back_populates="posts")
Step 4: Async Session & Query Integration
Pair annotated models with AsyncSession and select() constructs. Use session.scalars(select(Model)).all() to preserve type narrowing. Avoid session.query() in async workflows; it bypasses 2.0 type inference pipelines, returns Any, and defeats the purpose of strict annotations.
from typing import List
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
async def get_active_users(session: AsyncSession) -> List[User]:
result = await session.execute(select(User))
return result.scalars().all() # Static analyzers correctly infer List[User]
Step 5: Static Analysis & Linter Configuration
Configure mypy or pyright with SQLAlchemy 2.0’s native PEP 561 stubs. Enable strict_optional = true and warn_return_any = true to catch annotation mismatches early. Add plugins = ['sqlalchemy.ext.mypy.plugin'] to mypy.ini or pyproject.toml for full ORM-aware type resolution. This configuration prevents silent None assignment errors and ensures server_default constraints align with runtime expectations.
Production Pitfalls & Error Context
| Pitfall | Exact Error Context | Resolution |
|---|---|---|
Mixing Column() and Mapped[] | mypy: Incompatible types in assignment (expression has type "Column[...]", variable has type "Mapped[...]") | Replace all legacy Column() calls with mapped_column(). |
Missing TYPE_CHECKING imports | NameError: name 'Post' is not defined at runtime | Wrap forward references in if TYPE_CHECKING: or use string literals "Post". |
Optional on Primary Keys | sqlalchemy.exc.IntegrityError: NOT NULL constraint failed | Primary keys are implicitly NOT NULL. Remove Optional/` |
Using session.query() in async | Static analyzer returns Any, losing autocomplete and type safety | Switch to session.execute(select(Model)).scalars().all(). |
Omitting server_default | Runtime None assignment errors despite strict type hints | Explicitly declare server_default=text("gen_random_uuid()") or mark as ` |
Frequently Asked Questions
How do I resolve 'Incompatible types in assignment' errors with Mapped?
Ensure you are using mapped_column() instead of Column(). The Mapped[T] type expects a ColumnElement or mapped_column instance, not a legacy Column object. Replace all legacy declarations to satisfy the generic type constraint.
Do I need sqlalchemy2-stubs for SQLAlchemy 2.0 type checking?
No. SQLAlchemy 2.0 ships with first-party PEP 561 compliant type stubs. Remove sqlalchemy2-stubs to avoid conflicting type definitions and duplicate symbol warnings in your IDE.
How do I type nullable columns correctly in 2.0?
Use Python 3.10+ union syntax: Mapped[str | None] = mapped_column(nullable=True). Avoid Optional[str] if targeting strict modern Python typing standards, as union syntax provides clearer static analysis boundaries and aligns with PEP 604.
Why does session.scalars() preserve types better than session.query()?session.scalars() returns a ScalarResult object that maintains the generic type parameter T, enabling static analyzers to infer exact model types without requiring explicit cast() calls or losing type safety.