>
dbwarden
/ why dbwarden

Models define the schema.
SQL is compiled.

dbwarden compiles SQL migrations from SQLAlchemy models. The models define the source; make-migrations writes versioned SQL with upgrade and rollback sections, reviewed in the pull request and verified against the database.

The models are the schema. The SQL follows.

In most migration workflows, the schema is described twice: once in the models, once in the migration scripts, and the two drift unless someone keeps reconciling them. The disagreement usually turns up in production, at the moment the database is asked to change. dbwarden keeps one definition: the SQLAlchemy models your application already imports.

Everything the database needs to look like is expressed there, including backend-specific options. The class Meta inner class holds comments, indexes, engines, and codecs beside the table they describe, and column-level Meta classes carry comments and visibility flags. All of it is validated when the module loads: an unknown attribute raises DBWardenConfigError instead of producing wrong DDL later.

make-migrations diffs that model state against the latest schema snapshot, falling back to the live database when no snapshot exists yet, and writes one versioned SQL file with an -- upgrade section and a -- rollback section.

The diff is canonicalized before comparison. A model says String(255) while PostgreSQL reports character varying(255); a Boolean default can render as false or FALSE; an identifier may be quoted in SQL and bare in model metadata; ClickHouse engine parameters can come back in a different order than they were declared. Handlers normalize both sides to the same representation first, so equivalent states produce no fake diffs, and a review never churns on whitespace or spelling that the database treats as identical.

Because generation starts from committed state, it is deterministic: the same model state and snapshot produce the same SQL every run. The migration file is derived output. Old migrations stay useful for review and deployment, but they no longer define the schema.

what one command derives
-- upgrade
CREATE TABLE IF NOT EXISTS users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) NOT NULL UNIQUE,
    bio TEXT
);

CREATE TABLE IF NOT EXISTS posts (
    id SERIAL PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    body TEXT NOT NULL,
    user_id INTEGER NOT NULL REFERENCES users(id),
    created_at TIMESTAMP NOT NULL
);

CREATE INDEX IF NOT EXISTS ix_posts_created_at ON posts (created_at);

-- rollback
DROP INDEX IF EXISTS ix_posts_created_at;
DROP TABLE posts;
DROP TABLE users;
the models, with typed metadata
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from dbwarden.databases import IndexSpec, TableMeta

class Base(DeclarativeBase):
    pass

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)
    bio: Mapped[str | None] = mapped_column(Text, nullable=True)

    class Meta(TableMeta):
        comment = "Core user accounts"

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, nullable=False)

    class Meta(TableMeta):
        indexes = [
            IndexSpec(name="ix_posts_created_at", columns=["created_at"]),
        ]
backend options on the model
from dbwarden.databases.pgsql import PGTableMeta, PGColumnMeta, pg

class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    bio: Mapped[str] = mapped_column(Text)

    class Meta(PGTableMeta):
        pg_fillfactor = 80

        class id(PGColumnMeta):
            pg = pg.field(identity="always", identity_start=100)

Change the model, generate the migration.

In a conventional workflow, a model change means hand-editing a migration script to match, and the two drift unless someone keeps reconciling them. dbwarden keeps the schema definition in the models your application already imports.

Change the model, run make-migrations, and the SQL is written beside the model change. The command prints the created file, which is what gets reviewed in the pull request and committed:

$ dbwarden make-migrations "create core tables" --database primary
Created migration: migrations/primary/primary__0001_create_core_tables.sql

The generated SQL is committed, reviewed, and deployed, but it is an output of the models, not a second schema maintained forever.

  • Schema review happens beside application code.
  • Generated SQL is readable by any DBA.
  • Old migrations are receipts, not structure.

Renames are where a naive diff turns destructive: a column disappears and a new one appears, and the tool guesses. dbwarden refuses to guess. When it detects a likely rename it emits ALTER TABLE ... RENAME COLUMN in both directions, and ambiguous cases are declared with --rename table.old:new or --rename-table old:new, which leaves a trace in the command history and the plan.

renames stay explicit
$ dbwarden make-migrations "rename name to full_name"     --rename users.name:full_name

# same shape, one command: add a column
$ dbwarden make-migrations "add bio"
a rename, not a drop
-- upgrade
ALTER TABLE users RENAME COLUMN name TO full_name;

-- rollback
ALTER TABLE users RENAME COLUMN full_name TO name;
the companion plan
{
  "migration_id": "primary__0003_rename_column_users_username",
  "operations": [
    {
      "type": "rename_column",
      "table": "users",
      "new_name": "email",
      "severity": "INFO",
      "resolved_from": "rename_flag"
    }
  ],
  "required_flags": [],
  "checksum": "sha256..."
}

Rollback and drift, checked at generation time.

Generated migrations carry an -- upgrade section and a -- rollback section in the same file, and both are computed from the same diff. The handler that emits a CREATE TABLE also knows what a DROP TABLE needs; the reverse operation is generated at the same time as the forward one, so the two can't drift apart.

dbwarden classifies the rollback before the file is accepted: real, conditional, irreversible, or placeholder. Placeholder rollback is refused by default; a change that cannot be reversed must declare that fact explicitly, so the irreversible case is visible in review rather than discovered during an incident.

$ dbwarden migrate --database primary
Applying migration: primary__0001_create_core_tables.sql
Migration applied successfully

$ dbwarden status --database primary
Database: primary
Applied migrations: 1
Pending migrations: 0

$ dbwarden history --database primary
1  primary__0001_create_core_tables.sql  applied

Version tables record which scripts ran; they don't prove the database still has the intended shape. Comparing models against live state, checksummed snapshots, or exported model state is what surfaces drift, and it happens at generation time, before the next migration is written.

Before anything applies, check reads the plan next to the pending migrations and classifies every operation: INFO for expected-safe additions, WARNING for changes that need review, ERROR for destructive or ambiguous ones. A destructive change does not fail silently in production; it stops in CI, where the acknowledgement is a deliberate act.

the safety check
$ dbwarden check --database primary

# drop_column on users.ssn: ERROR
# ack with the force flag after reviewing the plan
$ dbwarden check --database primary --force
a change that cannot be reversed
-- upgrade
ALTER TABLE users DROP COLUMN ssn;

-- rollback
-- dbwarden: irreversible
offline, from committed state
$ dbwarden export-models --database primary
$ git add .dbwarden/model_state.json

# in CI, with no database service:
$ dbwarden make-migrations "add bio" --offline
$ dbwarden check --database primary

Destructive changes are classified before they ship.

Every operation in a generated plan is classified: INFO for expected-safe additions, WARNING for changes that need review, ERROR for destructive or ambiguous ones. A destructive change fails the check until an operator acknowledges it with the force flag, which is an explicit record that a human reviewed the plan, not a bypass. A migration that drops a column can't merge silently; it stops in CI where the risk is visible.

The same command that classifies the SQL also looks at your application code. check-impact scans Python files and templates for references to the objects a destructive change would remove, using AST analysis with a grep fallback, and reports each hit with the file and line number. Schema safety and application safety come from the same step, so there is no separate review to forget.

Type changes get the same treatment. --safe-type-change expands a column type change into the multi-step sequence a careful engineer would write by hand: add the new column, backfill it, swap, drop, generated as reviewable SQL with the rollback beside it.

The safety page ↗
code that would break
$ dbwarden check-impact 0002 --database primary

Migration: 0002_drop_username
Impact detected: 1 operation(s) affect code

drop_column on users.username
  References: 2
    app/routes/users.py:34  attribute_access
      .username
    app/templates/profile.jinja2:12  grep
      user.username
a type change, expanded
$ dbwarden make-migrations "widen bio" --safe-type-change

-- upgrade
ALTER TABLE users ADD COLUMN bio_new TEXT;
-- (backfill from bio, swap, then drop bio)

-- rollback
-- (restores the original column)

What is applied, and whether the database agrees.

status shows the applied and pending counts for a database; history lists the full versioned sequence with each migration's state. After every applied migration, dbwarden writes a checksummed JSON snapshot of the schema to .dbwarden/schemas/, so the next generation diffs against committed state instead of trusting a version table.

diff compares models against the database or a snapshot and shows structural differences without writing anything; generation is the step that produces files. That separation keeps inspection read-only until you ask for output.

For an existing database, generate-models reads the live schema and writes SQLAlchemy models, so a legacy system can be adopted instead of rebuilt from scratch. --base plugs the output into your existing declarative base, and --tables / --exclude-tables scope a large adoption incrementally.

The state and operations page ↗
what is applied
$ dbwarden status --database primary
Database: primary
Applied migrations: 12
Pending migrations: 1

$ dbwarden history --database primary
1  primary__0001_create_core_tables.sql  applied
2  primary__0002_add_bio.sql              applied
...
diff, without writing
$ dbwarden diff --database primary

          Schema Diff
┏━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━┳━━━━━━━━━┓
┃ Operation    ┃ Table ┃ Target ┃ Severity ┃
┡━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━╇━━━━━━━━━┩
│ add_column   │ users │ email  │ INFO     │
│ drop_column  │ users │ ssn    │ WARNING  │
└──────────────┴───────┴────────┴─────────┘
an existing database, adopted
$ dbwarden generate-models --base app.models.Base     --tables users,posts --database primary

Views, grants, and routines in the same workflow.

Not every database object is a one-time schema evolution. Views, grants, functions, and triggers outlive a single deploy, and re-creating them by hand drifts from the repository. dbwarden supports three migration classes: versioned files, runs-always (RA__) for objects re-created on every migrate, and runs-on-change (ROC__) for routines reapplied only when their content changes.

A runs-always view is re-created on every migrate, so its definition is always whatever is in the repository, with no drift. Because the plan applies pending versioned files before runs-always files, a view that references a column added in the same deploy picks it up in the same run. The files carry the same upgrade and rollback contract as versioned migrations, so nothing lives outside the reviewable workflow.

The repeatable migrations page ↗
a runs-always view
-- primary__RA__refresh_active_users_view.sql
-- upgrade
CREATE OR REPLACE VIEW active_users AS
SELECT id, email FROM users WHERE is_active = TRUE;

-- rollback
DROP VIEW IF EXISTS active_users;
generated like any migration
$ dbwarden make-migrations "refresh active users"     --type ra --database primary

Reference data, tracked like migrations.

Baseline data for local, test, and sandbox environments is usually a script somewhere, applied once, and never reconciled with the code that needs it. dbwarden tracks seeds in a seed table independent of the migration history, so reference data gets the same versioned discipline as schema changes.

Code seeds live next to your models and are auto-versioned in the C namespace (C0001, C0002, ...) from deterministic ordering, so there is no manual version parameter to keep in sync. File seeds are plain SQL or Python in a seeds directory. Each seed row stores a SHA-256 checksum of its source, so a warning appears when the seed was modified since the last apply.

For environments that don't run your application code, seed export renders code seeds to stateless runs-on-change SQL, and seed rollback --count N removes the tracking records for a partial rollback.

The seeds page ↗
tracked, like migrations
$ dbwarden seed list --database primary
Seeds for database 'primary':
  V0001  seed_initial_users   applied  2025-06-01 10:00:00
  C0001  initial countries    pending   (code seed)

$ dbwarden seed apply --database primary
$ dbwarden seed rollback --count 1 --database primary
code seed, next to the model
from dbwarden.seed import Seed

class AdminUserSeed(Seed):
    __seed_description__ = "initial administrator"
    __seed_on_conflict__ = "update"
    __seed_conflict_columns__ = ["email"]

    model = User
    rows = [User(email="[email protected]")]

Metrics, JSON logs, and trace-level SQL.

Migrations are operational work, and dbwarden makes them observable. With DBWARDEN_METRICS=true, migrate and seed apply record counters, gauges, and histograms: migrations applied, migration errors, schema and seed version, pending migrations, and durations, all labeled by database. The FastAPI plugin exposes them at /metrics, so one scrape target covers both the app and the commands it runs.

DBWARDEN_LOG_JSON switches all log output to newline-delimited JSON for ELK, Loki, or Datadog. When you need to see exactly what ran against the database, --debug-level trace logs every SQL statement as it executes, and --perf adds per-statement timing.

The observability page ↗
metrics and json
$ DBWARDEN_METRICS=true dbwarden migrate
$ DBWARDEN_LOG_JSON=true dbwarden migrate     --debug-level trace --perf
Why do the models define the schema instead of the migration files?

Two representations of the same schema drift apart, and the disagreement usually shows up in production. dbwarden keeps one definition, the models, and treats migration files as derived output: committed for review and deployment, but not a second schema to maintain.

What is class Meta for?

Backend-specific options that have no SQLAlchemy-native spelling live beside the table they describe: comments, indexes, engines, codecs, identity, fill factor. Meta is validated when the module loads, so a typo raises DBWardenConfigError instead of producing wrong DDL later.

How does dbwarden detect renames?

When a column disappears and a new one appears, dbwarden emits ALTER TABLE ... RENAME COLUMN instead of a drop-and-create. Ambiguous cases are declared with --rename table.old:new or --rename-table old:new, which leaves a trace in the command history and the plan.

What is in the .plan.json file?

The typed operations that produced the SQL, with severity and required flags, plus a checksum. check reads it before anything applies, and migrate never executes it: the plan is metadata for review and CI, not an execution path.

Why is deterministic output important?

If two machines generate different SQL for the same models, review churns on fake changes and CI can’t be trusted. dbwarden canonicalizes both sides of the diff before comparing, so the same model state and snapshot produce the same SQL every run.

Choose dbwarden when

You use SQLAlchemy and want the models to be the schema, with the SQL you approve, frozen in the repo.

Choose something else when

You need one platform across several languages, or you're building with Django and should use its own migrations.

How declarative migrations work ↗dbwarden vs Alembic ↗Tool scope overview ↗