SQLAlchemy 2.0 #3 Defining ORM Models: DeclarativeBase, Mapped, mapped_column

4 min read

In part 2 we defined tables with Table objects. The ORM declares the same information as Python classes: one row becomes one object, and columns become attributes. Model declaration in 2.0 is built around type hints, so it looks quite different from 1.x. This part locks down that syntax.

The basic form: DeclarativeBase and Mapped #

models.py
from datetime import datetime
from typing import Optional

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


class Base(DeclarativeBase):
    pass


class User(Base):
    __tablename__ = "user_account"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(30))
    email: Mapped[str] = mapped_column(String(100), unique=True)
    nickname: Mapped[Optional[str]] = mapped_column(String(30))
    created_at: Mapped[datetime] = mapped_column(server_default=func.now())

    def __repr__(self) -> str:
        return f"User(id={self.id!r}, name={self.name!r})"

Taking the structure apart:

  • Base: the shared parent for every model in the project. Create one class inheriting DeclarativeBase, and a MetaData that registers its subclasses comes for free. Part 2’s metadata.create_all(engine) becomes Base.metadata.create_all(engine) in the ORM.
  • Mapped[type]: both the declaration that this attribute maps to a database column and a type hint. IDEs and mypy understand it as-is.
  • mapped_column(): carries the column details (primary key, length, uniqueness, defaults, and so on). If there is nothing to configure, it can be omitted — name: Mapped[str] alone yields a NOT NULL string column.

The division of labor is the key: the type and nullability are decided by Mapped[...]; everything else is decided by mapped_column().

Type mapping rules: Python types become database types #

The Python type inside Mapped[...] converts to a database type automatically.

Python typeDB type (typical mapping)Notes
intINTEGER
strVARCHARLength limits via mapped_column(String(30))
floatFLOAT
boolBOOLEANINTEGER 0/1 on SQLite
datetimeDATETIME / TIMESTAMPWith timezone: DateTime(timezone=True)
DecimalNUMERICThe right answer for money columns
bytesBLOB / BYTEA

And the single most important rule: Optional[str] (or str | None) means NULL allowed; otherwise NOT NULL. Because nullability is derived from the type hint, the model code, the actual schema, and type checking always agree. That is why only nickname allows NULL in the model above. You can override with mapped_column(nullable=...), but contradicting the type hint only breeds confusion.

Column settings you will use constantly #

models.py
from sqlalchemy import Text, text


class Post(Base):
    __tablename__ = "post"

    id: Mapped[int] = mapped_column(primary_key=True)
    # index: for columns frequently used in search conditions
    author_name: Mapped[str] = mapped_column(String(30), index=True)
    # Python-side default: applied when the object is created
    view_count: Mapped[int] = mapped_column(default=0)
    # server-side default: becomes the DEFAULT clause in DDL
    status: Mapped[str] = mapped_column(String(20), server_default=text("'draft'"))
    # unbounded text
    body: Mapped[str] = mapped_column(Text)
  • default vs server_default: default=0 means Python fills the value into the INSERT statement; server_default becomes the DEFAULT clause of CREATE TABLE and the database fills it. If access that bypasses SQLAlchemy (manual SQL, another service) is a possibility, server_default is the safe choice; for values only the ORM ever writes, default is enough. For created_at-style columns, server_default=func.now() is the standard.
  • index=True creates a single-column index. Composite indexes go in __table_args__ as Index("ix_post_author_status", "author_name", "status"). Why indexes matter for read performance is covered in What a Database Index Does.
  • unique=True creates a unique constraint. For columns where a duplicate is a bug — emails, usernames — do not leave it to application checks; enforce it as a database constraint.

Naming conventions: unify constraint names from day one #

If you do not name a constraint (unique, foreign key, and so on), the database invents a name. The problem comes later, when Alembic needs that name to drop or alter the constraint — and every database names things differently. That is why baking in a naming convention at the start is standard practice.

models.py
from sqlalchemy import MetaData

convention = {
    "ix": "ix_%(column_0_label)s",
    "uq": "uq_%(table_name)s_%(column_0_name)s",
    "ck": "ck_%(table_name)s_%(constraint_name)s",
    "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
    "pk": "pk_%(table_name)s",
}


class Base(DeclarativeBase):
    metadata = MetaData(naming_convention=convention)

With this, the unique constraint on user_account.email gets the predictable name uq_user_account_email on any database. This belongs in the project on day one. Introduce it after tables already exist, and it clashes with the old constraint names and makes migrations tedious.

A model and a table are two views of the same thing #

Behind a declared model sits exactly the Table object from part 2. You can pull it out as User.__table__, and Base.metadata holds the tables of every model. Remember that an ORM model is ultimately a bundle of Table definition + class mapping, and code that moves between Core and ORM stops feeling foreign.

main.py
print(User.__table__)             # Table('user_account', MetaData(), ...)
Base.metadata.create_all(engine)  # create all registered tables

Summary #

  • Models inherit from a Base built on DeclarativeBase; Base.metadata holds every table definition.
  • Mapped[type] doubles as column mapping and type hint; details go to mapped_column(). Optional means nullable, otherwise NOT NULL.
  • Use default for ORM-only values and server_default for schema-level defaults. Enforce no-duplicates with unique=True as a database constraint.
  • Set the naming convention on day one — the only way to avoid fighting constraint names in future migrations.
  • Next part: the Session that runs actual CRUD with these models, and how the ORM tracks changes and produces SQL for you.
X