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)
| Python | SQLAlchemy → DDL |
|---|---|
int | Integer |
float | Float |
str | String (no length; → TEXT in PG) |
bool | Boolean |
bytes | LargeBinary |
datetime | DateTime (no tz) |
date | Date |
time | Time |
Decimal | Numeric |
UUID | Uuid |
dict | JSON |
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 ofmapped_column(...).Mapped[str]when column should be nullable.- Missing
type_annotation_mapoverrides → undesired TEXT in production. - Forward references without
"..."inMapped[list["Post"]]. - Using
Optional[X]instead ofX | 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 .