2. Defining Models and Generating Migrations¶
What You'll Learn¶
- How to define SQLAlchemy models with
class Metaannotations - How
make-migrationsgenerates SQL from model changes - How the generated SQL maps to database DDL
- How to create manual migrations with
dbwarden new - How to extract rollback SQL from an existing migration
Prerequisites¶
- Completed Section 1: Project Setup
examples/core/withapp/models.py
Step 1: The Models¶
Our example project defines four models in examples/core/app/models.py. Here they are with explanations:
User¶
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
full_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
class Meta(TableMeta):
comment = "Core user accounts"
indexes = [
IndexSpec(name="ix_users_created_at", columns=["created_at"]),
]
Key points:
unique=TrueonemailandusernamegeneratesUNIQUEconstraintsnullable=True(the default) allowsNULL;nullable=FalseaddsNOT NULLserver_default=text(...)becomes a database-levelDEFAULTclause in the DDL;default=is a Python-level default and is not rendered in SQLclass Meta(TableMeta)is how we attach table-level metadataIndexSpecgenerates a namedCREATE INDEXstatement
Post¶
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
class Meta(TableMeta):
comment = "User blog posts"
indexes = [
IndexSpec(name="ix_posts_user_id", columns=["user_id"]),
IndexSpec(name="ix_posts_created_at", columns=["created_at"]),
]
Key points:
ForeignKey("users.id")generates aREFERENCESclause- Foreign key targets are rendered inline in
CREATE TABLE
Product (with CHECK constraint)¶
class Product(Base):
__tablename__ = "products"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
price: Mapped[float] = mapped_column(Float, nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
in_stock: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
class Meta(TableMeta):
comment = "Product catalog"
checks = [
{"name": "ck_products_price_positive", "sql": "price > 0"},
]
Key points:
checksinclass MetageneratesCHECKconstraints- Each check needs a
name(constraint name) andsql(the expression) - This prevents negative prices at the database level
Tag¶
class Tag(Base):
__tablename__ = "tags"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
class Meta(TableMeta):
comment = "Taxonomy tags for products"
The simplest model: just an ID and a unique name.
Step 2: Generating the Migration¶
The first script step runs:
This compares the current model state against the database (or a stored snapshot). Since this is a fresh project, it detects four new tables and generates:
-- upgrade
CREATE TABLE IF NOT EXISTS posts (
id INTEGER NOT NULL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id),
created_at DATETIME
)
CREATE TABLE IF NOT EXISTS products (
id INTEGER NOT NULL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
price FLOAT NOT NULL,
description TEXT,
in_stock BOOLEAN DEFAULT TRUE,
created_at DATETIME
)
CREATE TABLE IF NOT EXISTS tags (
id INTEGER NOT NULL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE
)
CREATE TABLE IF NOT EXISTS users (
id INTEGER NOT NULL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
username VARCHAR(100) NOT NULL UNIQUE,
full_name VARCHAR(200),
is_active BOOLEAN DEFAULT TRUE,
created_at DATETIME
)
-- rollback
DROP TABLE users
DROP TABLE tags
DROP TABLE products
DROP TABLE posts
Note: The example uses SQLite, which has limited DDL support. With PostgreSQL, DBWarden generates additional features: -
CREATE INDEX IF NOT EXISTS ...: fromIndexSpecentries inclass Meta-COMMENT ON TABLE ...: fromMeta.commentattributes -CONSTRAINT ... CHECK (...): fromMeta.checks-server_defaultexpressions rendered as native SQL defaults - InlineREFERENCESbecome table-levelFOREIGN KEYconstraintsThe generated SQL is always backend-specific. DBWarden adapts to the
database_typeconfigured indbwarden.py.
Reading the Generated SQL¶
Let's walk through what each section does:
-- upgrade: Applied when you run dbwarden migrate
-
CREATE TABLE IF NOT EXISTS posts (...): Creates posts with a foreign key reference tousers(id)(inlineREFERENCESstyle for SQLite). The foreign key originates fromForeignKey("users.id")on theuser_idcolumn. -
CREATE TABLE IF NOT EXISTS products (...): Creates products with aCHECKconstraint defined inclass Meta. In SQLite, CHECK constraints must be inline; with PostgreSQL they becomeCONSTRAINT ... CHECK (...). -
CREATE TABLE IF NOT EXISTS tags (...): Simple table with a unique constraint onname. -
CREATE TABLE IF NOT EXISTS users (...): Creates the users table with all columns, primary key, and unique constraints inline.
Note that with this SQLite backend the table order differs from the order in our Python models, and some features are omitted:
- IndexSpec entries generate CREATE INDEX only on PostgreSQL and ClickHouse
- COMMENT ON TABLE is only generated for PostgreSQL
- server_default expressions render as native SQL defaults on PostgreSQL
-- rollback: Applied when you run dbwarden rollback
- Drops tables. Order may vary by backend; DBWarden handles dependency ordering automatically.
Auto-generated Migration Name¶
The migration file is named automatically:
The naming pattern is:
PostgreSQL-Specific Model Metadata¶
When your database_type is "postgresql", DBWarden supports PostgreSQL-specific table and column metadata. The following model shows tablespace, fillfactor, identity columns, and column compression:
from dbwarden.databases.pgsql import PGTableMeta, PGColumnMeta, pg
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
total: Mapped[float] = mapped_column(Float)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP)
class Meta(PGTableMeta):
pg_tablespace = "fast_space"
pg_fillfactor = 90
comment = "Customer orders"
class id(PGColumnMeta):
comment = "Order ID"
pg = pg.field(identity="ALWAYS")
class created_at(PGColumnMeta):
pg = pg.field(compression="pglz")
The generated PostgreSQL DDL includes tablespace, fillfactor, identity columns, and column-level options:
CREATE TABLE IF NOT EXISTS orders (
id INTEGER GENERATED ALWAYS AS IDENTITY NOT NULL,
total FLOAT NOT NULL,
created_at TIMESTAMP NOT NULL COMPRESSION pglz
) TABLESPACE fast_space WITH (fillfactor=90);
COMMENT ON TABLE orders IS 'Customer orders';
COMMENT ON COLUMN orders.id IS 'Order ID';
Step 3: Creating a Manual Migration¶
Sometimes you need a migration that isn't model-driven: a data backfill, a stored procedure, or a complex SQL operation.
This creates a blank migration:
You fill in both sections manually. Manual migrations follow the same file naming convention and are tracked alongside auto-generated ones.
Step 4: Extracting Rollback SQL¶
If you have a migration file and need to extract just its rollback section:
This prints the rollback SQL to stdout. Useful for quickly verifying what a rollback will do before running it.
Key Takeaways¶
- DBWarden generates explicit, reviewable SQL: no hidden runtime behavior
- Every migration has both
-- upgradeand-- rollbacksections class Meta(TableMeta)is where table-level metadata (comments, indexes, checks) livesIndexSpecproduces namedCREATE INDEXstatements; always prefer named indexesdbwarden newcreates blank migrations for non-model-driven changesdbwarden make-rollbackextracts rollback SQL for review