Using mapped_column() instead of Column() in SQLAlchemy 2.0

Replace id = Column(Integer, primary_key=True) with id: Mapped[int] = mapped_column(primary_key=True) — the Python type and the nullability move into the Mapped[] annotation, and every structural argument stays exactly where it was. This page is the mechanical companion to typed declarative models and Mapped annotations, which covers how the annotation is resolved into a SQL type in the first place.

Quick Answer

Column() splits into an annotation and a keyword list A legacy declaration, id = Column(Integer, primary_key=True) and email = Column(String(255), nullable=False, unique=True), is split into two parts. The Python type and the nullability move into the Mapped annotation: Mapped[int] and Mapped[str]. Everything structural — primary_key, unique, index, ForeignKey, server_default — stays as a keyword argument to mapped_column. The SQL type itself moves to the registry type_annotation_map, so it is stated once for the project rather than on every column. Column(String(255), nullable=False, unique=True) type, nullability and constraints all in one call the attribute itself is untyped nullable= must be kept in step by hand Mapped[str] = mapped_column(unique=True) type and nullability come from the annotation String(255) comes from the registry, once only the constraint remains a keyword A converted class should generate an EMPTY migration. If autogenerate proposes a change, the annotation and the live table disagree — which is worth knowing before you ship.

Before — legacy declarative:

from sqlalchemy import Column, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import declarative_base, relationship

Base = declarative_base()


class Order(Base):
    __tablename__ = "orders"

    id = Column(Integer, primary_key=True)
    reference = Column(String(64), nullable=False, unique=True)
    customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False, index=True)
    total = Column(Numeric(12, 2), nullable=False)
    note = Column(String(255), nullable=True)

    customer = relationship("Customer", back_populates="orders")

After — SQLAlchemy 2.0 annotated declarative:

from __future__ import annotations

import decimal

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


class Base(DeclarativeBase):
    type_annotation_map = {str: String(255), decimal.Decimal: Numeric(12, 2)}


class Order(Base):
    __tablename__ = "orders"

    id: Mapped[int] = mapped_column(primary_key=True)
    reference: Mapped[str] = mapped_column(String(64), unique=True)
    customer_id: Mapped[int] = mapped_column(ForeignKey("customers.id"), index=True)
    total: Mapped[decimal.Decimal]
    note: Mapped[str | None]

    customer: Mapped[Customer] = relationship(back_populates="orders")

Both classes produce byte-identical DDL. What changed is that Order.total is now Decimal to a type checker, Order.note is str | None, and nullable= has stopped being a second place where nullability is recorded.

Execution Context & Async Workflow Integration

Nothing about execution changes: mapped_column() produces the same Column object internally, and the statements you build against the converted class are the same statements. What changes is what a type checker knows about the results, which matters most in async code where a mistake surfaces as a runtime exception rather than a wrong value.

# Async — the converted model carries its types through execution
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 unpaid_total(customer_id: int) -> decimal.Decimal:
    async with Session() as session:
        stmt = select(Order.total).where(Order.customer_id == customer_id)
        result = await session.execute(stmt)
        # scalars() is Sequence[Decimal] to the checker, so this sum is typed
        return sum(result.scalars(), start=decimal.Decimal("0"))

Under the legacy class, Order.total was Any and the start=Decimal("0") argument would have gone unchecked — including the common variant that passes start=0 and produces a TypeError only when the query happens to return rows.

The conversion also interacts with expire_on_commit. A converted model does not change expiry behaviour, but because the attributes are now typed, a None arriving from an expired attribute is a type error your checker can see, where before it was invisible. Async applications generally set expire_on_commit=False on the sessionmaker for the reasons covered in using expire_on_commit=False in FastAPI dependencies.

Where each Column argument ends up Six arguments and their destinations. The SQL type moves to the registry type_annotation_map or an Annotated alias. nullable becomes the presence or absence of None in the annotation. primary_key, index and unique stay exactly as they were, as keyword arguments to mapped_column. default stays too, and keeps its meaning of a Python-side default applied at flush; server_default likewise stays and keeps its meaning of DDL the database applies. type String(255), Numeric(12, 2) → registry or Annotated alias stated once per project nullable nullable=False → Mapped[str] nullable=True → Mapped[str | None] primary_key / index / unique unchanged → mapped_column keyword no annotation equivalent default / server_default unchanged → mapped_column keyword Python-side vs DDL, as before Only the first two rows move. Everything structural stays where it was, which is why the conversion is mechanical enough to do class by class without a schema change.

Resolving Warnings, Errors & Common Mistakes

Error / WarningRoot CauseProduction Fix
ArgumentError: Could not locate SQLAlchemy Core type for Python type <class 'decimal.Decimal'>The annotation names a Python type that is neither in type_annotation_map nor built in.Add it to the registry map. One entry fixes every column of that type across the project.
NameError: name 'Customer' is not definedA forward reference in a relationship annotation without from __future__ import annotations.Add the future import at the top of every model module.
Autogenerate proposes alter_column(nullable=True) after conversionThe annotation gained `None, or the legacy Columnhad nonullable=` and therefore defaulted to nullable.
Autogenerate proposes a type change to NUMERICAn unregistered Decimal fell through to the built-in default, which has no precision or scale.Register decimal.Decimal in type_annotation_map with the precision the table actually uses.
ArgumentError: Column expression expected on a relationshipAn annotated attribute was assigned a plain Column() rather than mapped_column().Use mapped_column() for every annotated attribute; the annotation is ignored otherwise.
The class imports fine but __init__ rejects a keywordThe class inherits MappedAsDataclass and the attribute order changed during conversion.Move defaulted fields after non-defaulted ones, or give the field an explicit default.

Advanced Conversion Optimization

Converting by hand is fine for a dozen classes and tedious for two hundred. The mechanical part — moving the type into an annotation and dropping nullable= — is regular enough to script, provided the script refuses to guess.

"""Rewrite legacy Column() declarations to annotated mapped_column().

Deliberately conservative: it converts only the shapes it fully understands and
leaves everything else untouched, with a report of what it skipped. A codemod that
guesses produces a diff nobody can review.
"""
from __future__ import annotations

import re

SIMPLE = re.compile(
    r"^(?P<indent>\s+)(?P<name>\w+)\s*=\s*Column\("
    r"(?P<type>Integer|String\(\d+\)|Numeric\(\d+,\s*\d+\)|Boolean|DateTime)"
    r"(?P<rest>.*)\)\s*$"
)

PY_TYPE = {
    "Integer": "int",
    "Boolean": "bool",
    "DateTime": "datetime.datetime",
}


def convert_line(line: str) -> tuple[str, bool]:
    m = SIMPLE.match(line)
    if not m:
        return line, False
    sql_type, rest = m.group("type"), m.group("rest")
    if sql_type.startswith("String"):
        py = "str"
    elif sql_type.startswith("Numeric"):
        py = "decimal.Decimal"
    else:
        py = PY_TYPE[sql_type]

    nullable = "nullable=True" in rest
    keywords = [
        k.strip() for k in rest.split(",")
        if k.strip() and not k.strip().startswith("nullable=")
    ]
    annotation = f"Mapped[{py} | None]" if nullable else f"Mapped[{py}]"
    call = f"mapped_column({', '.join(keywords)})" if keywords else ""
    suffix = f" = {call}" if call else ""
    return f"{m.group('indent')}{m.group('name')}: {annotation}{suffix}\n", True

Two rules make a codemod like this safe. It must skip anything it does not recognise — a Column with a default= callable, a custom type, an inline ForeignKey with ondelete — and report the skips so they can be done by hand. And its output must be verified by autogenerate rather than by review alone: an empty migration is the only proof that the rewrite preserved the schema. The wider strategy for running this across a large codebase without a long-lived branch is in the legacy 1.4 to 2.0 codemod checklist.

Proving the conversion is a no-op A four-step verification loop, run per class. Convert exactly one class, so any diff has one possible cause. Run alembic revision --autogenerate against a database at the current head. Read the generated script: an empty upgrade body means the annotation says precisely what the table already holds. A non-empty body is real information — most often a column you believed was NOT NULL is nullable in the database, or a Decimal resolved to a NUMERIC without precision. Fix the annotation, delete the throwaway revision, and only then commit. convert one class one class per commit so a diff has one cause alembic revision --autogenerate against a database at head a throwaway revision read the upgrade body empty → the conversion is faithful non-empty → the annotation is wrong delete the revision, commit the class repeat for the next one The two diffs you will actually see: a column that is nullable in the database but not in the annotation, and an unregistered Decimal that resolved to NUMERIC with no precision.

Verifying the Conversion Against the Live Schema

A conversion that changes the schema by accident is worse than no conversion, because the change ships inside a commit whose message says "typing only". The verification is cheap enough to run on every class, and it is the only thing that actually proves the rewrite was faithful.

"""Assert that the models and the live database agree — run this in CI.

Uses Alembic's own comparison machinery rather than reimplementing it, so what the
test compares is exactly what `alembic revision --autogenerate` would compare.
"""
from __future__ import annotations

import asyncio

from alembic.autogenerate import compare_metadata
from alembic.migration import MigrationContext
from sqlalchemy.ext.asyncio import create_async_engine

from myapp.models import Base


async def assert_models_match_database(url: str) -> None:
    engine = create_async_engine(url)
    try:
        async with engine.connect() as connection:
            diff = await connection.run_sync(
                lambda sync_conn: compare_metadata(
                    MigrationContext.configure(sync_conn), Base.metadata
                )
            )
    finally:
        await engine.dispose()

    if diff:
        lines = "\n".join(f"  - {entry}" for entry in diff)
        raise AssertionError(f"models and database disagree:\n{lines}")


if __name__ == "__main__":
    asyncio.run(assert_models_match_database("postgresql+asyncpg://app:secret@localhost/orders"))

Run this against a database migrated to head, in CI, on every pull request. Before a conversion it should already pass; after a conversion it must still pass. When it fails, the diff entries name the table and column, so the cause is usually obvious within a line or two: a column that is nullable in the database and not in the annotation, an unregistered Decimal, or a String whose length in the registry map does not match the column that was actually created years ago.

The last of those is worth expecting. A project that has been running for a while frequently has columns of several different VARCHAR lengths, and a single str: String(255) registry entry will disagree with every column that is not 255. The fix is not to abandon the registry entry but to give the exceptions their own Annotated aliases — str64, str1000 — which documents the exception at the point it applies.

Frequently Asked Questions

Is mapped_column() a drop-in replacement for Column()?

Almost. It accepts the same structural keywords and produces the same Column internally, so primary_key, index, unique, ForeignKey, default and server_default all behave identically. The differences are that it may be used without a type when an annotation supplies one, and that it participates in annotation resolution where Column() does not — an annotated attribute assigned a plain Column() silently ignores its annotation.

Can I convert one class at a time, or does the whole base have to move together?

One at a time, and that is the recommended pace. Annotated and legacy classes coexist in a single registry with no special handling. Converting one class per commit also means that when autogenerate does produce a diff, there is exactly one candidate explanation.

Do I still need nullable=False anywhere?

Not on an annotated attribute — Mapped[str] already means NOT NULL. Passing both is not an error but it creates a second place the fact is recorded, which is exactly the drift the annotation was introduced to remove. On a non-annotated attribute during migration, keep it.

What happens to Column objects declared outside a class, in a Core Table?

Nothing. Core Table definitions keep using Column() and are unaffected by any of this; mapped_column() is an ORM declarative construct. A hybrid codebase that maps ORM classes onto Core tables continues to work unchanged.