Cheatsheet for schema declaration. Long-form: Textbook Ch 3 .

Base

from sqlalchemy import MetaData
from sqlalchemy.orm import DeclarativeBase

NAMING_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=NAMING_CONVENTION)

Naming conventions = predictable Alembic migrations.

Mapped columns

from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from sqlalchemy import String, Text, BigInteger, Numeric, DateTime, func

class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(unique=True, nullable=False, index=True)
    name: Mapped[str | None] = mapped_column(String(120))
    bio: Mapped[str | None] = mapped_column(Text)
    salary: Mapped[Decimal] = mapped_column(Numeric(12, 2))
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        server_default=func.now(),
    )
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        server_default=func.now(),
        onupdate=datetime.utcnow,
    )

Nullability rules

email: Mapped[str]                # NOT NULL
email: Mapped[str | None]         # NULL
email: Mapped[str | None] = mapped_column(default=None)

Defaults

# Python-side (runs at ORM-level insert)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)

# DB-side (DEFAULT in DDL; preferred for consistency)
created_at: Mapped[datetime] = mapped_column(server_default=func.now())

# On update (Python-side; runs on UPDATE via ORM)
updated_at: Mapped[datetime] = mapped_column(onupdate=datetime.utcnow)

Primary keys

# Single
id: Mapped[int] = mapped_column(primary_key=True)

# Composite
class OrderItem(Base):
    order_id: Mapped[int] = mapped_column(primary_key=True)
    line: Mapped[int] = mapped_column(primary_key=True)

# UUID
import uuid
from sqlalchemy import Uuid
class Doc(Base):
    id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)

Foreign keys

from sqlalchemy import ForeignKey

class Post(Base):
    id: Mapped[int] = mapped_column(primary_key=True)
    author_id: Mapped[int] = mapped_column(
        ForeignKey("users.id", ondelete="CASCADE"),
        index=True,
    )

ondelete: CASCADE, SET NULL, RESTRICT, NO ACTION.

Indexes

# Inline
email: Mapped[str] = mapped_column(unique=True, index=True)

# Composite / named
from sqlalchemy import Index

class User(Base):
    __tablename__ = "users"
    email: Mapped[str]
    active: Mapped[bool]
    
    __table_args__ = (
        Index("ix_users_active_email", "active", "email"),
    )

Constraints

from sqlalchemy import CheckConstraint, UniqueConstraint

class Order(Base):
    __tablename__ = "orders"
    id: Mapped[int] = mapped_column(primary_key=True)
    amount: Mapped[Decimal]
    
    __table_args__ = (
        CheckConstraint("amount > 0", name="ck_orders_amount_positive"),
        UniqueConstraint("user_id", "code", name="uq_orders_user_code"),
    )

Generated columns

from sqlalchemy import Computed

class Product(Base):
    name: Mapped[str]
    name_lower: Mapped[str] = mapped_column(Computed("lower(name)", persisted=True))

Enums

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))

Postgres uses native ENUM; SQLite stores as string. For schema-flexible, use VARCHAR + CHECK.

JSON / JSONB

from sqlalchemy import JSON
class Event(Base):
    payload: Mapped[dict] = mapped_column(JSON)

# Postgres-specific JSONB:
from sqlalchemy.dialects.postgresql import JSONB
class Event(Base):
    payload: Mapped[dict] = mapped_column(JSONB)

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)

class SoftDeleteMixin:
    deleted_at: Mapped[datetime | None]

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

table_args (ends with dict for kwargs)

class User(Base):
    __tablename__ = "users"
    email: Mapped[str]
    
    __table_args__ = (
        UniqueConstraint("email"),
        Index("ix_users_email", "email"),
        {"comment": "App users", "schema": "public"},
    )

Custom types

from sqlalchemy.types import TypeDecorator, String

class LowercaseString(TypeDecorator):
    impl = String
    cache_ok = True
    
    def process_bind_param(self, value, dialect):
        return value.lower() if value else value

Cross-DB type table

TypePostgresMySQLSQLite
String(N)VARCHAR(N)VARCHAR(N)TEXT
TextTEXTTEXTTEXT
BooleanBOOLEANTINYINTINTEGER
DateTime(tz=True)TIMESTAMPTZDATETIMETEXT
JSONJSON/JSONBJSON 5.7+TEXT
NumericNUMERICDECIMALNUMERIC
UUIDUUID nativeCHAR(36)TEXT

Common mistakes

  • Mapped[str] for nullable column → can’t insert NULL.
  • VARCHAR(255) by reflex — Postgres TEXT is fine.
  • No tz on datetime — store everything as DateTime(timezone=True).
  • Missing index on FK — slow joins / cascade deletes.
  • No naming conventions — ambiguous Alembic outputs.

Read this next

If you want my Base + mixins + naming-convention starter, 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 .