Database Migrations in PormG
PormG provides a structured way to manage database schema changes through migrations, inspired by Django but tailored for Julia and PostgreSQL/SQLite workflows.
What Are Migrations?
Migrations are version-controlled scripts that describe changes to your database schema. They allow you to:
- Create new tables and fields
- Alter or remove existing fields
- Apply incremental changes
- Keep your database schema in sync with your Julia models
- Track migration history with checksums and status
How it Works: State-Based Reconciliation
PormG follows a State-Based migration philosophy (similar to modern tools like Flyway, Prisma, or Atlas, rather than purely change-based like standard Django).
- Active Introspection: PormG reads your
connection.ymlfile, connects to the specified live database, and introspects its actual physical schema. - Comparison: It compares that live schema against your in-memory Julia
Modelsloaded in the current runtime session. - Diffing: It calculates the exact "delta" required to move the database to the target state defined in your code.
- Generation: It produces a standalone Julia script (
pending_migrations.jl) containing the DDL commands.
What this means in practice
Because every plan is a fresh diff between your models and the live database, PormG migrations behave differently from Django's migration graph — and the differences are deliberate:
makemigrationsnever reads previous migration files. Each run compares your models against the live schema only; there is no dependency graph and no replay of earlier migrations to reconstruct state. Migration order does not accumulate — the database itself is the accumulated state.- Migration files are an audit trail, not the source of truth.
pending_migrations.jland everything underapplied_migrations/record what was done; they are never re-read to plan or apply anything. Editing an already-applied file has no effect on future migrations — don't do it, it only desyncs the archive from the authoritativepormg_migrationstable. - You can regenerate freely. A pending draft you dislike can be dropped with
discard_pending_migration("db")and re-generated from scratch; there is no graph to keep consistent. - "Drift" means the live schema diverging from your models — an out-of-band
ALTER/DROP, say — not an edited migration file. It is surfaced the normal way: the nextmakemigrationsplans to reconcile it, andstatus()reports drift signals. Verifying old migration-file checksums buys you nothing here.
There is no migration graph, no dependencies list, and no per-file state replay. Read each makemigrations as diff(your models, the live database) — closer to Prisma / Atlas / Flyway's declarative diffing than to Django's ordered migration chain.
Terminology Mapping
If you are new to Django-style ORMs, the migration APIs map directly to standard universal database schema-management concepts:
| PormG Command | Django Concept | Universal DB / SQL Concept |
|---|---|---|
makemigrations("db") | makemigrations | Schema Diffing & Script Generation (compares code to live DB and generates DDL scripts). |
migrate("db") | migrate | Schema Deployment / Execution (applies the DDL scripts to the live database). |
init_migrations("db") | — | Bootstrap / Initialization (registers/creates history tables on an existing database). |
dry_run("db") | — | Dry Run / Plan Preview (previews the DDL statements without executing them). |
discard_pending_migration("db") | — | Discard Generated Script (deletes the un-applied pending_migrations.jl draft; no DB state changes). |
Migration History Table
PormG uses a pormg_migrations table as the canonical runtime source of truth for tracking applied migrations. This table is created automatically when you run migrate() or init_migrations().
Each migration record contains:
- version: A unique timestamp-based identifier (YYYYMMDDHHmmssSSS)
- name: A human-readable migration name
- checksum: SHA-256 hash of the SQL content for integrity verification
- sql_content: The full SQL that was applied
- applied_at: Timestamp of when the migration was applied
- status: One of
applied,failed - is_destructive: Whether the migration contained DROP operations
- format_version: The frozen migration-format contract version (see Migration Format Stability)
Filesystem archives (applied_migrations/) remain useful for version control and review, but the history table is authoritative.
The exact on-disk file layout, checksum algorithm, and tracking-table columns are a stability contract — see Migration Format Stability.
Database-Specific Behavior
SQLite: Table Recreation
SQLite has limited ALTER TABLE support. It can rename tables/columns and add or drop plain columns, but it cannot change a column's type, modify nullability/UNIQUE/CHECK constraints in place, remove a foreign key (there is no ALTER TABLE ... DROP CONSTRAINT), or DROP COLUMN on a column that participates in a FOREIGN KEY, a UNIQUE constraint, or the PRIMARY KEY.
To handle any of those changes, PormG automatically rebuilds the table from your model:
- Creates a new table with the desired schema.
- Copies existing data from the old table into it (surviving columns only).
- Re-creates the surviving indexes and foreign keys — an index on a dropped column is not re-created.
- Drops the old table, renames the new one, and runs
PRAGMA foreign_key_checkto catch orphaned rows.
The rebuild is emitted as plain DDL that composes with the migration's transaction, so no data is lost and the remaining indexes and constraints are preserved. This is what makes removing a foreign-key field or constraint, a UNIQUE column, or a PRIMARY KEY column work on SQLite even though DROP COLUMN/DROP CONSTRAINT alone cannot express it. Changes SQLite can do in place — adding a column, or dropping an ordinary column (not part of a FOREIGN KEY, UNIQUE, or PRIMARY KEY) — use ALTER TABLE directly, without a rebuild.
This process is transparent to the user but may take longer on very large tables.
Removing a column that is the table's only primary key diverges by backend. PostgreSQL's DROP COLUMN drops the column and its PRIMARY KEY constraint natively, leaving a table with no primary key. SQLite cannot express that without silently degrading the table to a rowid table, so PormG fails makemigrations loudly instead — declare a replacement primary key, or make the change manually. Dropping a primary-key column while the model still declares a primary key (the key moved to another column) rebuilds normally on both backends.
Advisory locking is not available for SQLite. Migration safety is single-instance only. Do not run concurrent migrations against the same SQLite database.
PostgreSQL: Advisory Locking
PostgreSQL migrations automatically acquire an advisory lock (pormg_migrations_{db_name}) to prevent concurrent migration execution. This ensures safe deployment in multi-instance environments.