Cheatsheet for SQLAlchemy 2.0 typing. The Mapped/mapped_column style is the only way forward for new code.

Mapped[T] basics

class User(Base):
    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str]               # NOT NULL, inferred String
    name: Mapped[str | None]         # NULL
    age: Mapped[int | None] = mapped_column(default=None)

Python type tells nullability:

  • Mapped[X] → NOT NULL.
  • Mapped[X | None] → NULL.

Inferred type mapping (defaults)

PythonSQLAlchemy → DDL
intInteger
floatFloat
strString (no length; → TEXT in PG)
boolBoolean
bytesLargeBinary
datetimeDateTime (no tz)
dateDate
timeTime
DecimalNumeric
UUIDUuid
dictJSON

Custom type_annotation_map

Override defaults globally:

from sqlalchemy import String, Text
from sqlalchemy.dialects.postgresql import JSONB
from datetime import datetime
from sqlalchemy.types import DateTime

class Base(DeclarativeBase):
    type_annotation_map = {
        str: String(255),                            # all str → VARCHAR(255)
        datetime: DateTime(timezone=True),           # all datetime → TIMESTAMPTZ
        dict: JSONB,                                 # all dict → JSONB
    }

Now name: Mapped[str] → VARCHAR(255). Override per column:

bio: Mapped[str] = mapped_column(Text)

Annotated types (PEP 593)

from typing import Annotated
from sqlalchemy import String

str120 = Annotated[str, mapped_column(String(120))]
intpk = Annotated[int, mapped_column(primary_key=True)]
nullable_str = Annotated[str | None, mapped_column(default=None)]

class User(Base):
    id: Mapped[intpk]
    name: Mapped[str120]
    bio: Mapped[nullable_str]

DRY for common shapes.

Explicit type via mapped_column

class Order(Base):
    id: Mapped[int] = mapped_column(primary_key=True)
    code: Mapped[str] = mapped_column(String(8))
    description: Mapped[str | None] = mapped_column(Text)
    amount: Mapped[Decimal] = mapped_column(Numeric(12, 2))
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

Lists / dicts (JSON-backed)

from sqlalchemy.dialects.postgresql import JSONB, ARRAY

class Doc(Base):
    tags: Mapped[list[str]] = mapped_column(ARRAY(String))      # Postgres array
    metadata_: Mapped[dict] = mapped_column(JSONB)
    config: Mapped[dict | None] = mapped_column(JSONB, default=None)

Relationship type hints

class User(Base):
    posts: Mapped[list["Post"]] = relationship(back_populates="author")
    profile: Mapped["Profile | None"] = relationship(back_populates="user", uselist=False)

class Post(Base):
    author: Mapped[User] = relationship(back_populates="posts")

mypy / pyright infers user.posts: list[Post].

Generic mixins

class TimestampMixin:
    created_at: Mapped[datetime] = mapped_column(server_default=func.now())
    updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=datetime.utcnow)

Mixins compose with Base:

class User(TimestampMixin, Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)

column_property

For computed read-only attrs based on SQL:

from sqlalchemy.orm import column_property

class Post(Base):
    title: Mapped[str]
    body: Mapped[str]
    title_lower: Mapped[str] = column_property(func.lower(title))

hybrid_property

For attrs computed in Python and in SQL:

from sqlalchemy.ext.hybrid import hybrid_property

class User(Base):
    first: Mapped[str]
    last: Mapped[str]
    
    @hybrid_property
    def full_name(self) -> str:
        return f"{self.first} {self.last}"
    
    @full_name.expression
    @classmethod
    def full_name(cls):
        return func.concat(cls.first, " ", cls.last)

# Now both work:
user.full_name              # Python
await s.execute(select(User).where(User.full_name == "Alice X"))   # SQL

Custom column types (TypeDecorator)

from sqlalchemy.types import TypeDecorator, String
from pydantic import BaseModel

class PydanticJSONB(TypeDecorator):
    impl = JSONB
    cache_ok = True
    
    def __init__(self, model_cls, **kwargs):
        self.model_cls = model_cls
        super().__init__(**kwargs)
    
    def process_bind_param(self, value, dialect):
        if value is None: return None
        return value.model_dump() if isinstance(value, BaseModel) else value
    
    def process_result_value(self, value, dialect):
        if value is None: return None
        return self.model_cls.model_validate(value)

class Event(Base):
    payload: Mapped["EventPayload"] = mapped_column(PydanticJSONB(EventPayload))

Enum types

import enum
from sqlalchemy import Enum as SAEnum

class Role(str, enum.Enum):
    USER = "user"; ADMIN = "admin"

class User(Base):
    role: Mapped[Role] = mapped_column(SAEnum(Role, name="user_role"))

PG: native ENUM. Others: stored as varchar.

mypy / pyright config

Enable strict mode in pyproject.toml:

[tool.mypy]
strict = true
plugins = ["sqlalchemy.ext.mypy.plugin"]

For pyright: works out of the box with Mapped[...].

Common mistakes

  • Column(...) (legacy) instead of mapped_column(...).
  • Mapped[str] when column should be nullable.
  • Missing type_annotation_map overrides → undesired TEXT in production.
  • Forward references without "..." in Mapped[list["Post"]].
  • Using Optional[X] instead of X | None (works but less modern).

Read this next

If you want my Annotated-type library + Pydantic-JSONB type, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .