Schema Migrations¶
Dazzle uses Alembic for all schema changes. This document covers the migration workflow for Dazzle app developers: how to generate, edit, and apply migrations as requirements evolve.
The worked examples here come from examples/invoice_ops, which was evolved through seven successive requirement changes (Changes 0–6) to exercise each migration pattern. The migration files are committed under examples/invoice_ops/.dazzle/migrations/versions/.
Policy reference: ADR-0017 — all schema changes, including framework entities, must go through Alembic. No raw DDL at startup.
The model¶
DSL is the source of truth. When you add an entity or change a field in your .dsl files, Alembic compares the DSL-derived SQLAlchemy metadata against the live database and generates a migration script. You review and edit the script, then apply it.
dazzle db wraps the Alembic CLI. dazzle serve runs alembic upgrade head automatically at startup — pending migrations are applied before the app accepts requests.
Project-database resolution¶
dazzle db resolves the target database in this priority order:
--database-urlflag (highest priority)DAZZLE_ENVprofile (if configured indazzle.toml)DATABASE_URLenvironment variable[database].urlindazzle.toml- Default:
postgresql://localhost:5432/dazzle
dazzle db also loads a .env file from the project root. The recommended pattern for local development is a gitignored .env at your project root:
First-time setup (two-step)¶
On a fresh database you must bootstrap in this order:
# Step 1 — apply the Dazzle framework baseline (creates _dazzle_params, etc.)
dazzle db upgrade
# Step 2 — generate the project baseline migration from your current DSL
dazzle db baseline
# Step 3 — apply the project baseline
dazzle db upgrade
dazzle db upgrade alone (Step 1) applies 0001_framework_baseline — the framework's own schema. dazzle db baseline (Step 2) then introspects your DSL and writes a migration that creates all your DSL-declared tables, revising from 0001_framework_baseline. Step 3 applies it. After this two-step setup, subsequent changes follow the normal revision → review → upgrade loop.
Normal change loop¶
# 1. Edit your DSL (add field, rename, split entity, etc.)
# 2. Generate a migration
dazzle db revision -m "describe the change"
# 3. Review and hand-edit the generated file (see patterns below)
# 4. Apply
dazzle db upgrade
# 5. Commit the migration file (see "Committing migrations" below)
Committing your migrations¶
Migration files are the schema-evolution record for your project. They must be version-controlled alongside your DSL.
The .dazzle/ directory is gitignored repo-wide (it contains generated state, caches, and lock files). A project un-ignores its migrations/versions/ subtree with a negation block. The examples/invoice_ops project demonstrates the pattern — see the repo .gitignore:
# invoice_ops commits its migration history — a project's migrations
# are the schema-evolution record and must be version-controlled.
!examples/invoice_ops/.dazzle/
examples/invoice_ops/.dazzle/*
!examples/invoice_ops/.dazzle/migrations/
examples/invoice_ops/.dazzle/migrations/*
!examples/invoice_ops/.dazzle/migrations/versions/
!examples/invoice_ops/.dazzle/migrations/versions/**
examples/invoice_ops/.dazzle/migrations/versions/**/__pycache__/
examples/invoice_ops/.dazzle/migrations/versions/**/*.pyc
For your own project, add an equivalent block to the repo .gitignore (or to a project-level .gitignore if your project is in a standalone repo):
# Commit migration history but not generated state
!<your_project>/.dazzle/
<your_project>/.dazzle/*
!<your_project>/.dazzle/migrations/
<your_project>/.dazzle/migrations/*
!<your_project>/.dazzle/migrations/versions/
!<your_project>/.dazzle/migrations/versions/**
<your_project>/.dazzle/migrations/versions/**/__pycache__/
<your_project>/.dazzle/migrations/versions/**/*.pyc
Autogenerate noise to expect¶
Every dazzle db revision (autogenerate) emits two categories of spurious operations that must be stripped by hand before applying the migration.
1. op.drop_table('_dazzle_params')¶
_dazzle_params is a framework-internal table owned by 0001_framework_baseline. Because build_metadata() only includes DSL-declared entities, Alembic thinks the table is absent from the target schema and emits a drop. It is not — do not drop it.
The baseline migration (5c144ea092ef_baseline_create_all_tables.py) shows the comment that should accompany the strip:
# NOTE: _dazzle_params is a framework table owned by 0001_framework_baseline.
# Autogenerate incorrectly flags it as "removed" because build_metadata only
# includes DSL-declared entities. Do NOT drop it here.
2. Unnamed unique-constraint re-emissions on id columns¶
Alembic cannot reconcile unnamed unique constraints (UniqueConstraint(None, ...)) that were created by create_all() on id columns. It re-emits op.create_unique_constraint(None, ...) on every table in every autogenerated migration. Strip these — they are no-ops that will fail at runtime if not removed.
Rule: after dazzle db revision, always scroll to the bottom of the generated file and remove any op.drop_table('_dazzle_params') and any op.create_unique_constraint(None, ...) lines before reviewing the substantive change.
Pattern: additive field¶
DSL change: add a new optional field to an existing entity.
Autogenerate result: correct — emits op.add_column. Strip the noise, no other edits needed.
Worked example: 2026_05_21_08934671d5d5_add_po_number_to_invoice.py (Change 1)
def upgrade() -> None:
# Hand-edited: stripped spurious _dazzle_params drop and unnamed
# unique-constraint ops emitted by autogenerate. Column type kept as
# sa.Text() — Dazzle maps str/str(N) to TEXT (back/runtime/sa_schema.py);
# the (40) length is an application-layer concern, not a DB column type.
op.add_column("Invoice", sa.Column("po_number", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("Invoice", "po_number")
Note on types: str, str(N), and enum[...] in DSL all map to sa.Text() at the database layer (src/dazzle/back/runtime/sa_schema.py). The (N) length limit and the enum value list are application-layer constraints enforced by Dazzle, not PostgreSQL column types. Migrations always show sa.Text() for string columns — that is correct and expected.
Pattern: field rename¶
DSL change: rename a field on an existing entity.
Autogenerate result: WRONG — autogenerate cannot detect renames. It emits op.drop_column followed by op.add_column, which destroys the existing data in that column.
Worked example: 2026_05_21_e3c4b12a8018_rename_supplier_bank_reference_to_bank_.py (Change 2)
What autogenerate produces (data-destroying — do not apply):
# DO NOT USE — this destroys data
def upgrade() -> None:
op.drop_column("Supplier", "bank_reference")
op.add_column("Supplier", sa.Column("bank_account_ref", sa.Text(), nullable=False))
The hand-edited migration that preserves data:
def upgrade() -> None:
op.alter_column("Supplier", "bank_reference", new_column_name="bank_account_ref")
def downgrade() -> None:
op.alter_column("Supplier", "bank_account_ref", new_column_name="bank_reference")
Rule: whenever you rename a DSL field, discard the autogenerated drop/add pair and replace it with a single op.alter_column(..., new_column_name=...) call.
Pattern: enum evolution¶
DSL change: add a new value to an existing enum[...] field.
Autogenerate result: nothing substantive — after stripping the standard noise, the migration body is empty.
Worked example: 2026_05_21_7cf317f60a5f_add_partially_paid_to_invoice_status.py (Change 3)
def upgrade() -> None:
# Enum evolution — no-op: Invoice.status is unconstrained TEXT in PostgreSQL.
# Dazzle maps DSL enum fields to sa.Text() with no CHECK constraint, so adding
# 'partially_paid' to the enum values list requires no DDL change.
pass
def downgrade() -> None:
# Enum evolution — no-op: see upgrade() comment.
pass
Trade-off: because Dazzle enums are stored as unconstrained TEXT, adding (or removing) an enum value requires no DB migration. The downside is that PostgreSQL will accept any string in that column — enum validity is enforced only at the Dazzle application layer, not at the DB layer.
Pattern: entity split and data backfill¶
DSL change: extract fields from an existing entity into a new entity (entity split), where existing rows must have their data migrated to the new table before the old columns are dropped.
Autogenerate result: partially correct — emits op.create_table and op.drop_column, but with no backfill step. Applying it as-is loses the data that was in the dropped column.
Worked example: 2026_05_21_7b4f5f16a753_split_supplierbankaccount_out_of_.py (Change 4)
The hand-edited migration uses a strict three-step ordering: create the new table, backfill from the old column, then drop the old column. Reversing steps 2 and 3 loses data.
def upgrade() -> None:
op.create_table(
"SupplierBankAccount",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("tenant_id", sa.Uuid(), nullable=False),
sa.Column("supplier", sa.Uuid(), nullable=False),
sa.Column("bank_account_ref", sa.Text(), nullable=False),
sa.Column("account_name", sa.Text(), nullable=False),
sa.Column("iban", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["supplier"], ["Supplier.id"]),
sa.ForeignKeyConstraint(["tenant_id"], ["Tenant.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("id"),
)
# Backfill: one bank-account row per existing supplier, BEFORE dropping the column.
op.execute(
"""
INSERT INTO "SupplierBankAccount"
(id, tenant_id, supplier, bank_account_ref, account_name, created_at, updated_at)
SELECT gen_random_uuid(), tenant_id, id, bank_account_ref, name, now(), now()
FROM "Supplier"
WHERE bank_account_ref IS NOT NULL
"""
)
op.drop_column("Supplier", "bank_account_ref")
def downgrade() -> None:
op.add_column("Supplier", sa.Column("bank_account_ref", sa.Text(), nullable=True))
op.execute(
"""
UPDATE "Supplier" s
SET bank_account_ref = sba.bank_account_ref
FROM "SupplierBankAccount" sba
WHERE sba.supplier = s.id
"""
)
op.alter_column("Supplier", "bank_account_ref", nullable=False)
op.drop_table("SupplierBankAccount")
Rule: the ordering in upgrade() is non-negotiable — create first, backfill second, drop third. Use op.execute with a raw SQL INSERT ... SELECT for the backfill. gen_random_uuid() is available on PostgreSQL 13+.
Pattern: event-schema change¶
DSL change: modify an event_model block — change retention, add or rename an event field, add an event type.
Autogenerate result: empty (after stripping noise).
Worked example: 2026_05_21_321d3b7c99d8_invoice_events_retention_.py (Change 5)
def upgrade() -> None:
# event_model is runtime-only — no DDL produced
pass
def downgrade() -> None:
# event_model is runtime-only — no DDL produced
pass
The event_model construct is runtime-only. Events are not backed by PostgreSQL tables — no DDL change is needed when the event schema changes.
Gap: no event-schema versioning in event_model. Unlike the hless construct (which has version and compatibility fields — ADDITIVE/BREAKING — in its IR), the simpler event_model DSL has no event-versioning mechanism: no version tag, no schema registry, no upcaster. Adding a required field to an event is a silent breaking change for existing consumers reading stored events that pre-date the new field. If your application stores and replays events from event_model topics and needs schema evolution with backward compatibility, use hless instead, which was designed for that requirement.
Pattern: changes that need no migration¶
Some DSL changes have no schema impact at all. The generated migration is an intentional empty pass.
Worked example: 2026_05_21_f43cc3604cf7_add_finance_admin_persona.py (Change 6 — RBAC change)
def upgrade() -> None:
# RBAC-only change — no schema impact
pass
def downgrade() -> None:
# RBAC-only change — no schema impact
pass
Changes in this category:
| DSL change | Why no migration |
|---|---|
permit: / forbid: rules |
Compile to query filters, not schema |
scope: / as: clauses |
Compile to SQL predicates, not columns |
| Adding or removing a persona | Personas are DSL-level roles, not tables |
| Changing a story or rhythm | Test/specification metadata, no DB backing |
Changing event_model retention or event fields |
Runtime-only (see above) |
Adding a schedule or webhook |
Resolved at startup from DSL, no new tables |
Even when the migration is an empty pass, generating and committing it is still the right practice — it keeps the revision chain intact and documents that you considered the schema impact and found none.
Rollback and safety¶
Downgrade¶
# Step back one revision (default behaviour)
dazzle db downgrade
# Step back to a specific revision
dazzle db downgrade <revision_id>
# Step back N steps
dazzle db downgrade -2
Note: the target revision is a positional argument. dazzle db downgrade with no arguments steps back one revision. Passing a negative integer like -1 as a flag (e.g. dazzle db downgrade --target -1) does not work — use the positional form.
Verify FK integrity¶
After applying a migration that creates or drops FK relationships, verify the database state:
This checks foreign-key integrity and flags orphaned rows or broken constraints.
Snapshot and restore¶
Before applying a risky migration (entity split, column drop, large backfill), take a snapshot of the current database:
dazzle db snapshot # capture current state
# ... apply migration ...
dazzle db restore # roll back to snapshot if something goes wrong
Use snapshots as a development safety net. In production, rely on your database provider's point-in-time recovery (PITR) — Heroku Postgres, RDS, and similar services support this natively.
Safe migration checklist¶
Before applying any destructive migration (rename, drop, split):
- Back up or snapshot first — especially in production.
- Review the autogenerate output — do not apply raw autogenerate on rename or split; hand-edit first.
- Strip noise — remove
op.drop_table('_dazzle_params')and any unnamed unique-constraint ops. - Test the downgrade path — run
dazzle db downgradeanddazzle db upgradeon a copy of the database before touching production. - Commit the migration file — before deploying, confirm the migration is committed and pushed.