SQLAlchemy 2.0 #3 Defining ORM Models: DeclarativeBase, Mapped, mapped_column
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 #
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 inheritingDeclarativeBase, and aMetaDatathat registers its subclasses comes for free. Part 2’smetadata.create_all(engine)becomesBase.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 type | DB type (typical mapping) | Notes |
|---|---|---|
int | INTEGER | |
str | VARCHAR | Length limits via mapped_column(String(30)) |
float | FLOAT | |
bool | BOOLEAN | INTEGER 0/1 on SQLite |
datetime | DATETIME / TIMESTAMP | With timezone: DateTime(timezone=True) |
Decimal | NUMERIC | The right answer for money columns |
bytes | BLOB / 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 #
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)defaultvsserver_default:default=0means Python fills the value into the INSERT statement;server_defaultbecomes the DEFAULT clause of CREATE TABLE and the database fills it. If access that bypasses SQLAlchemy (manual SQL, another service) is a possibility,server_defaultis the safe choice; for values only the ORM ever writes,defaultis enough. Forcreated_at-style columns,server_default=func.now()is the standard.index=Truecreates a single-column index. Composite indexes go in__table_args__asIndex("ix_post_author_status", "author_name", "status"). Why indexes matter for read performance is covered in What a Database Index Does.unique=Truecreates 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.
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.
print(User.__table__) # Table('user_account', MetaData(), ...)
Base.metadata.create_all(engine) # create all registered tablesSummary #
- Models inherit from a
Basebuilt onDeclarativeBase;Base.metadataholds every table definition. Mapped[type]doubles as column mapping and type hint; details go tomapped_column().Optionalmeans nullable, otherwise NOT NULL.- Use
defaultfor ORM-only values andserver_defaultfor schema-level defaults. Enforce no-duplicates withunique=Trueas 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.