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 and MySQL 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).

  1. Active Introspection: PormG reads your connection.yml file, connects to the specified live database, and introspects its actual physical schema.
  2. Comparison: It compares that live schema against your in-memory Julia Models loaded in the current runtime session.
  3. Diffing: It calculates the exact "delta" required to move the database to the target state defined in your code.
  4. 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:

  • makemigrations never 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.jl and everything under applied_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 authoritative pormg_migrations table.
  • 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 next makemigrations plans to reconcile it, and check("db"; kinds = [:schema_drift]) reports it without writing anything. (status() reports on the migration history, not the schema.) Verifying old migration-file checksums buys you nothing here.
  • Both sides are compared as columns, not as field types. Each declared field compiles to a canonical description of the column it renders — type, nullability, key, uniqueness, default, foreign key, CHECKs, identity — and the live schema is read straight into the same description from the catalog. Two fields that render the same column (CharField, URLField and SlugField with the same length; a ForeignKey(unique = true) and a OneToOneField) are therefore one column to the diff, and nothing about a live column is inferred from which field type it "looks like".
Adopting a schema PormG did not create

Because the live side is read as facts, a column that no declaration could produce shows up as a one-time plan rather than being silently equated with the nearest field type — after it is applied, the schema converges:

  • a SMALLINT / INTEGER UNSIGNED column without its >= 0 CHECK plans ADD CHECK once against a PositiveSmallIntegerField / PositiveIntegerField. Only a CHECK of exactly col >= 0 (the one PormG writes) counts as that CHECK: a range such as CHECK (grid >= 0 AND grid <= 30) is read as your own constraint, is never planned away, and does not stand in for it;
  • a bytea / BLOB column's byte bound is read the same way: only a CHECK of exactly octet_length(col) <= n (length(col) <= n on SQLite and MySQL), the one BinaryField(max_length = n) writes, counts as that bound — and only up to 1 GiB, the largest max_length a BinaryField accepts; a CHECK above that constrains nothing and is left unread. A compound check such as CHECK (octet_length(photo) <= 1048576 AND octet_length(photo) > 0) on a BinaryField() is your own constraint and is never planned away. On a BinaryField(max_length = n) column it does not stand in for the bound, which plans ADD CHECK once;
  • a foreign-key column with no index plans CREATE INDEX once against a ForeignKey (which declares db_index = true by default) — declare db_index = false if you do not want one;
  • a lengthless varchar or an unparameterised numeric plans the declared width once;
  • a column type PormG has no field for (macaddr, citext, an array of smallint or jsonb, character(n)) never matches a declared TextField or CharField: generate_models_from_db emits TextField for it with a warning, and makemigrations plans a retype unless you exclude the table or declare the column by hand.

"Never planned away" is about the reader: no plan targets your own CHECK. On SQLite, though, any plan that alters a column of that table (the ADD CHECK cases above included) is a table rebuild that re-creates the table from your models, so a CHECK you wrote by hand is not carried across it — makemigrations warns, quoting it, when that happens. To keep one, declare it as a CheckConstraint under its own name with the same condition: it is adopted as it stands and re-created by every rebuild after that. A declared CHECK that the model no longer declares is planned away — PormG stores a marker beside each CHECK it creates, and that marker is what makes it PormG's to drop.

Tables PormG created itself always carry these facts, so nothing changes for them.

Coming from Django?

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.

There is no migrate app 0003 or rollback either. To go back, plan against the older models file — makemigrations("db"; models_file = …) — as described in Reverting by declaring the old state.


What makemigrations Manages, Ignores, and Would Drop

makemigrations owns the tables your models declare: their columns, keys, the indexes and constraints a model can declare, and the tables no model declares any more. Everything else in the database falls into one of the groups below. "Destructive" means dry_run() lists the statement and migrate() refuses it without destructive = true (see Destructive Operations Safety).

What is in the databaseWhat makemigrations does
Views and materialized views; functions and procedures; sequences; types, enums and domains; extensions; EXCLUDE constraints; tables in a PostgreSQL schema other than public; table comments, and column comments other than PormG's own markerNever reads them, so never drops or alters them. A column comment is read only for the pormg:default: marker PormG stamps when it applies an expression db_default on PostgreSQL and MySQL. Re-stamping replaces that marker and keeps the rest of the comment.
PostgreSQL partitions of a partitioned table, and tables an extension owns (PostGIS's spatial_ref_sys); SQLite virtual tables (fts5, rtree) and their shadow tablesNever reads them, whatever ignore_table says.
Tables listed in a connection's ignore_tables:, in register_ignore_tables!, or in the backend's built-in list (less the connection's unignore_defaults:)Never reads them. A managed model on an ignored table is refused with InvalidConfigurationError.
Tables of unmanaged modelsNever creates, alters, renames or drops them. They are still read, so that the plan's index names and SQLite views can be checked against them.
A table no model declaresPlans DROP TABLE, destructive. Refused with InvalidMigrationError while a view or trigger still reads it (see Deleting a Model).
A column, a plain index or a UNIQUE constraint the model does not declarePlans its removal, destructive. See Changing composites on an existing table for indexes.
The same plain one-column index more than once on a column, under several namesPlans the removal of every copy but one when the field declares db_index = true, and of every copy when it does not; destructive. See Index names and duplicate indexes.
A non-unique index with an access method (GIN, GiST, BRIN, hash, SP-GiST), a DESC column or an operator class — the shapes Models.Index can declare — or an expression, an explicit collation, a NULLS placement other than the direction's default or a WHERE clause, which it declares as SQL textReads it. Plans its removal only when it carries PormG's pormg:index marker, i.e. PormG created it; one written by hand is never planned away, and a declaration of the same shape adopts it — for an expression or partial one, a declaration of its own text as the database spells it. A method or an operator class is PostgreSQL-only. On MySQL a declaration of the same shape adopts a hand-made index without marking it, and a hand-made expression index matches no declaration, since MySQL re-prints it with backtick-quoted names (Changing composites).
A CHECK you wrote by handReads it but never plans it away. On SQLite, a rebuild of the table loses it, with a warning (see below). A CheckConstraint the model no longer declares is planned away.
An index that a model cannot declare, over several columns or a non-unique one over one column: an extension's access method (bloom), on SQLite a collation declared on the column rather than in the index (any but BINARY), or a unique index with a method, direction, operator class or INCLUDE (a unique expression or WHERE clause is a declarable UniqueConstraint, and is read). On PostgreSQL, also any invalid index, whatever its shape (a failed CREATE INDEX CONCURRENTLY). On MySQL, a FULLTEXT or SPATIAL index, one with a prefix length (surname(10)) and one mixing columns and expressions (#1369)Never reads it, so never drops it. An invalid one is reported by check("db"; kinds = [:invalid_index]) (see Finding invalid indexes), and dropping or rebuilding it is left to you. On MySQL, inspectdb leaves such an index out of the model with a warning naming it. On SQLite a rebuild re-creates it, unless it covers a column the rebuild removes. A field that declares db_index = true on a column carrying only such an index gets PormG's own index beside it on PostgreSQL, and on MySQL. On SQLite, a non-unique index that lists the column itself is taken as satisfying it and nothing is created, unless that index is partial, has an expression member or carries PormG's pormg:index marker; otherwise PormG's own index is created there too.
Views and triggers on a table SQLite has to rebuildDropped and re-created around the rebuild. Refused when one would come back stale (see Triggers and views across a rebuild).
Table clauses no model can express, on a table SQLite rebuilds: a hand-written CHECK, STRICT, WITHOUT ROWID, a column COLLATE, a generated column, ON CONFLICT, a composite foreign key, a key's ON UPDATE / DEFERRABLE / MATCHLost, with one warning per table quoting them (see SQLite: Table Recreation). SQL comments inside the CREATE TABLE text are lost too, without a warning.
A column whose type PormG has no field for (macaddr, citext, an array of smallint or jsonb, a custom type)Plans a retype to the declared type, converting the column, unless you exclude the table (see Adopting a schema PormG did not create, above).

When SQLite cannot confirm which tables are a virtual table's shadows on PormG's connection (its module is an extension your app loads elsewhere, such as sqlite-vec; the module does not report shadow tables; or SQLite is older than 3.37), every table named <name>_… is skipped and a warning lists them. Do not give your own tables that prefix: a model declaring one would plan CREATE TABLE and fail at migrate, because the table exists.

check("db"; kinds = [:schema_drift]) reports a finding for exactly the steps makemigrations would plan. So it never reports the objects PormG does not read. The one such object worth hearing about, an invalid PostgreSQL index, has its own class: kinds = [:invalid_index].

"Never read" does not mean "never affected". On SQLite a table rebuild drops and re-creates the views and triggers that name the table, as the table above says. On PostgreSQL a DROP TABLE uses CASCADE, and PormG refuses it only for the dependents PostgreSQL records. A trigger function written in PL/pgSQL that names the table is not one of them (see Deleting a Model).

To change an object outside this scope, such as a view, a trigger or a function, use your own SQL outside the migration plan. To keep makemigrations away from a table you manage that way, list it in ignore_tables:.

Index names and duplicate indexes

A field with db_index = true gets an index named <table>_<column>_idx, from the physical table and column (result_driverid_idx), the one-column spelling of the name a Models.Index derives. A name longer than the engine allows (63 bytes on PostgreSQL, 64 characters on MySQL) is cut and ends in 8 hex digits of its own hash, so two long names never meet. When another index already holds the name — one a renamed column or table left behind, since PormG never renames an existing index — the new index takes <table>_<column>_<8 hex>_idx instead. Every one of these names is the same on every run, so on PostgreSQL and SQLite a plan applied twice hits CREATE INDEX IF NOT EXISTS and creates nothing the second time.

The planner checks the name against the indexes it reads. It does not read an invalid PostgreSQL index or an index on an ignored table, so one of those holding the name makes the CREATE INDEX IF NOT EXISTS a no-op, and the next plan proposes it again. An invalid index is reported by check("db"; kinds = [:invalid_index]); drop it and the plan creates the index.

A Models.Index without name= can derive the same name as a db_index index: an Index over driverid and raceid and a column named driverid_raceid both derive result_driverid_raceid_idx. When neither exists yet, the Index keeps that name and the db_index index takes the hashed one. When the db_index index already holds it, makemigrations raises InvalidMigrationError: give the Index an explicit name=.

Indexes created before this naming carry a random suffix (result_driverid_a1b2c3d4_idx). They keep it: nothing reads an index name, so PormG does not rename them.

A column can end up carrying the same index more than once, under several names. A plan that read a column as unindexed created one more copy each time it ran, because a random name never matched IF NOT EXISTS. makemigrations reads every copy, and a copy here means a plain, non-unique one-column index with no WHERE, expression, direction, operator class or collation of its own:

  • The field declares db_index = true — every copy but one is planned away, each as its own Remove duplicate index <index> on <column> step. The one kept is the copy named <table>_<column>_idx if there is one, otherwise the first by name.
  • The field does not declare it — every copy is planned away in one plan: Remove index on <column> for one of them, Remove duplicate index … for the rest.
-- result.driverid declares db_index = true and carries three copies of its index:
DROP INDEX IF EXISTS "result_driverid_s21bkzlw_idx";
DROP INDEX IF EXISTS "result_driverid_zndqsv7m_idx";

Each is a DROP INDEX, so migrate() needs destructive = true. No data is lost: the copy kept indexes the same rows.


Terminology Mapping

If you are new to Django-style ORMs, the migration APIs map directly to standard universal database schema-management concepts:

PormG CommandDjango ConceptUniversal DB / SQL Concept
makemigrations("db")makemigrationsSchema Diffing & Script Generation (compares code to live DB and generates DDL scripts).
migrate("db")migrateSchema 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).
check("db")—Schema Compatibility Report (read-only; lists live-schema facts the models cannot faithfully express).
check("db"; kinds = [:schema_drift])closest: makemigrations --check (which compares against migration files, not the live schema)Drift Detection (read-only; lists every difference between the live schema and the declared models — a CI / release gate).
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 destructive operations: a DROP, a TRUNCATE, or a DELETE with no WHERE (see Destructive Operations Safety)
  • 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.

Changing a Foreign Key

Changing what a foreign key points at is a change to the constraint, not to the column. PormG plans it when you change any of:

  • the target model — ForeignKey(Status) → ForeignKey(RaceStatus) (a model bound RaceStatus maps to table racestatus; set db_table if you want another name)
  • the target column — a different pk_field, or a parent whose key is renamed through db_column
  • the referential action — on_delete = CASCADE → on_delete = SET_NULL

On PostgreSQL a constraint cannot be re-pointed in place, so the plan drops it and adds it back. Re-pointing Result.statusid from Status to a new RaceStatus model, with on_delete = CASCADE, generates:

ALTER TABLE "result" DROP CONSTRAINT "result_statusid_a1b2c3d4_fk";
ALTER TABLE "result" ADD CONSTRAINT "result_statusid_zmidlrtp_fk" FOREIGN KEY ("statusid") REFERENCES "racestatus" ("statusid") ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;

The dropped constraint is named from the live catalog; the new one gets a fresh random suffix, so the two never collide.

On SQLite the same change goes through the table rebuild described below, which re-renders the whole FOREIGN KEY ... REFERENCES ... ON DELETE clause from your model. MySQL drops and adds the key, as PostgreSQL does (DROP FOREIGN KEY, then ADD CONSTRAINT). The backends therefore agree on the outcome; only the DDL differs.

Re-pointing a foreign key needs `destructive = true`

The PostgreSQL plan contains DROP CONSTRAINT, and MySQL's DROP FOREIGN KEY, so dry_run() classifies the migration as destructive and migrate() refuses it until you opt in with migrate("db", destructive = true). Nothing is dropped except the constraint itself — no column, no data.

The new constraint is DEFERRABLE INITIALLY DEFERRED, so it is validated when the migration commits. If any existing row holds a value that does not exist in the new parent, the commit fails and the whole migration rolls back. Re-point the data first, or make the column nullable and clear it, before changing the model. On MySQL, which checks a key at each statement, migrate counts the rows with no parent before the plan runs and refuses it with MigrationPrecheckError.

Renaming a field

A rename plans the rename. PostgreSQL and SQLite carry a column's own baggage across ALTER TABLE ... RENAME COLUMN — its indexes, its UNIQUE constraint, its PRIMARY KEY, and its FOREIGN KEY — so when nothing else about the field changed, that one statement is the whole plan. Renaming Result.statusid to Result.racestatusid, with everything else about the key unchanged:

ALTER TABLE "result" RENAME COLUMN "statusid" TO "racestatusid";

MySQL renames in one ALTER TABLE that also renames the column's own unique index, and drops and re-adds the CHECKs MySQL will not let a column take along (error 3959). That drop makes the rename destructive (MySQL Guide). That is all, on PostgreSQL and SQLite, and it holds for a plain field, an indexed field (ForeignKey sets db_index = true by default), a unique = true field, and one that is both.

The index keeps its old name. An index created for statusid stays result_statusid_idx while covering racestatusid. That is cosmetic: nothing reads the name — PormG matches an index by its real column membership — and makemigrations sees the column as indexed on both sides afterwards, so it plans nothing further. The alternative would be dropping and re-creating the index on every rename, which rebuilds it from scratch on a large table to change a string nobody reads.

The foreign key likewise keeps its pre-rename name, and that too is harmless: PormG looks a key up by its table and column, never by a name convention, so a later drop or re-point finds it.

When a rename carries more than a rename

A rename is the same column change with a new name, so if the field also changed, the plan carries that change too — the RENAME COLUMN first, then exactly what an ordinary alteration of that column would have emitted. Rename Result.statusid to Result.racestatusid and change the key — a different parent, a different target column, or a different ON DELETE; any of the three re-issues the constraint — and you get the drop, the rename, and the new constraint, in that order:

ALTER TABLE "result" DROP CONSTRAINT "result_statusid_a1b2c3d4_fk";

ALTER TABLE "result" RENAME COLUMN "statusid" TO "racestatusid";

ALTER TABLE "result" ADD CONSTRAINT "result_racestatusid_wpkbcx73_fk"
  FOREIGN KEY ("racestatusid") REFERENCES "status" ("statusid") ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;

The drop comes first and names the pre-rename column, because that is what the live catalog knows when the plan is built; the add names the new one, because by then the rename has run. On SQLite the same change is one table rebuild, emitted after the RENAME COLUMN.

Two renames on the same table in one migration are fine, and so is a rename alongside a new column or an ordinary column alteration: SQLite collapses them into a single rebuild placed after every rename and every ADD COLUMN, and that rebuild re-creates the renamed columns' secondary indexes under their new names.

Renaming a field that also changes db_index

Turning db_index on or off in the same change as the rename is planned in that migration, on every backend — a CREATE INDEX on the new column name, or a DROP INDEX of the live index. Leaving db_index alone plans nothing at all about the index: RENAME COLUMN carries an index with it on every engine, so the index keeps covering the column and only its name still mentions the old one. That stale name is deliberate — re-creating the index on every rename would rewrite a potentially large index to fix a string.

A rename that DROPS a constraint needs `destructive = true`

Renaming a field is not destructive. But if the same change also removes a UNIQUE constraint or a PRIMARY KEY, the plan contains DROP CONSTRAINT (PostgreSQL), DROP INDEX or DROP PRIMARY KEY (MySQL), or a table rebuild whose DROP TABLE is part of the recreation (SQLite) — and dry_run() classifies each as destructive, so migrate() refuses it until you opt in with migrate("db", destructive = true).

On SQLite the opt-in is needed for any rename that also changes the column — a retype, a nullability change, a re-pointed key — because SQLite alters a column by recreating the table. The recreation copies every row; no column and no data are lost, but DROP TABLE is what the classifier sees. On PostgreSQL the same changes are in-place ALTER COLUMN statements and need no opt-in unless a constraint is genuinely being dropped, and so are they on MySQL (RENAME COLUMN, then MODIFY COLUMN). MySQL will not let a column take its CHECK along (error 3959), so a rename there drops the CHECK and adds it back in the same statement: renaming a field that carries one of PormG's column CHECKs (a PositiveIntegerField's >= 0, a BinaryField(max_length = …) bound) needs the opt-in even when nothing else changes.

Measured per shape (dry_run().statements through Migrations.is_destructive):

the rename also…PostgreSQLSQLiteMySQL
…changes nothing elsenot destructivenot destructivenot destructive, unless the column carries one of PormG's column CHECKs
…retypes the column, or changes nullnot destructivedestructivenot destructive
…drops a UNIQUE or PRIMARY KEYdestructivedestructivedestructive
Repairing a `UNIQUE` constraint dropped by an older PormG

Before this behavior was fixed, renaming a unique = true field destroyed the constraint on PostgreSQL and aborted the migration on SQLite. The plan dropped the index backing the constraint, and on PostgreSQL that meant dropping the constraint first — successfully, and with nothing to put it back.

PostgreSQL is the case worth checking, because it is silent: the migration reported success, and introspection reads unique back correctly afterwards, so the model compares converged and makemigrations will never propose restoring it. SQLite failed loudly and rolled back, so a SQLite database was never left in this state.

Check any table where you renamed a unique field, then re-add what is missing:

-- What UNIQUE constraints does the table actually have?
SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint
WHERE conrelid = 'driver'::regclass AND contype = 'u';

-- Look for duplicates FIRST — re-adding the constraint fails on dirty data,
-- and the window in which it was missing is when duplicates could arrive.
SELECT "driverslug", count(*) FROM "driver"
GROUP BY "driverslug" HAVING count(*) > 1;

ALTER TABLE "driver" ADD CONSTRAINT "driver_driverslug_key" UNIQUE ("driverslug");

On SQLite there is no ALTER TABLE ... ADD CONSTRAINT; if you need to add one by hand, CREATE UNIQUE INDEX "driver_driverslug_key" ON "driver" ("driverslug"); enforces the same rule.

Repairing a duplicate left by an older PormG

Before this behavior was fixed, a rename that left the foreign key unchanged added a second, identical constraint beside the one the rename carried along. Both point at the same parent with the same action, so inserts and deletes behave identically and nothing surfaces the problem — the model compares converged, so makemigrations will never propose removing it.

It is worth cleaning up rather than ignoring, because the next change to that key only removes one of the two. A re-point would drop one constraint and add the new one, leaving a stale constraint still pointing at the old parent, permanently and invisibly.

List the foreign keys on the table and drop the extras by name:

SELECT conname FROM pg_constraint
WHERE conrelid = 'result'::regclass AND contype = 'f';

ALTER TABLE "result" DROP CONSTRAINT "result_statusid_a1b2c3d4_fk";

SQLite is unaffected — it has no ALTER TABLE ADD CONSTRAINT, so the duplicate was never possible there.

Renaming a model that has a many-to-many field

A ManyToManyField without through= gets an auto join table named after its model, with one column per end: Team.drivers is stored in team_drivers (id, team_id, driver_id). Rename the team table to squad and both names change with it, to squad_drivers and squad_id.

You answer one question, the one about the model. The join table and its column follow:

ALTER TABLE "team" RENAME TO "squad";
ALTER TABLE "team_drivers" RENAME TO "squad_drivers";
ALTER TABLE "squad_drivers" RENAME COLUMN "team_id" TO "squad_id";

Every link row is kept. The same holds when the model at the other end is renamed: driver_id follows driver, while the join table keeps its name.

A join table is followed only when it is unambiguous. Exactly one vanished table must have the same number of columns and keys to the same two tables once the rename is applied, and only one endpoint column may fit each end. Otherwise PormG asks, as it does for any other table or field. That happens, for example, with a self-referential relation whose from_… and to_… columns both changed.

Deleting a Model

Removing a model from models.jl plans a DROP TABLE for its table. That is destructive, so migrate asks first, or needs destructive = true when nothing can answer (see Destructive Operations Safety).

PormG manages tables, not the views, triggers and other objects built on them. It never drops one of those for you, and it does not drop a table while one of them still reads it. makemigrations fails instead with an InvalidMigrationError naming every object in the way, and writes no plan:

ERROR: Cannot drop table "driver": this migration drops it because no model declares it any more, but other objects still read it:
  - view "result_driver" reads "driver"
  - view "driver_standings" reads "driver"
PormG manages tables, not the views, triggers and other objects built on them, so it will not drop them for you: …

What counts as reading the table depends on the engine, because each one would lose the object differently:

  • PostgreSQL drops a table with CASCADE, which PormG keeps for the foreign keys that point at it (see Statement Ordering). CASCADE would also remove everything else that depends on the table, without the plan saying so. PormG checks what PostgreSQL records as depending on the table: views and materialized views (and views built on those), rules on other tables, policies, functions with a SQL-standard body, a column of another table whose type is this table's row type (or an array of it), and another table's default that draws on this table's sequence. Two things are not seen. The first is a function whose body is a string, which covers PL/pgSQL (typically a trigger function) and LANGUAGE sql AS '…'. PostgreSQL records no dependency for such a body, so the function survives the drop and fails when it next runs. The second is an object created after makemigrations and before migrate: CASCADE still takes that one.
  • SQLite would keep the objects, naming a table that no longer exists. Every later ALTER TABLE … RENAME in the database would then fail on them, and every table rebuild ends in one. PormG checks views that read the table, views built on those, INSTEAD OF triggers on them, and triggers on other tables that name any of them. It reads names, as the rebuild check below does, so a view with a column that shares the dropped table's name is refused too.
  • MySQL has no CASCADE: PormG drops the table with the session's foreign-key checks off, so the keys of children the same plan alters dangle until their own steps drop them. PormG checks the views that read the table (information_schema.VIEW_TABLE_USAGE), and refuses a foreign key to it from a table the plan does not manage — an ignored table, an unmanaged model's, or one in another database.

Triggers on the dropped table go with it, on both engines, and never block the drop.

To get through, drop the listed views and triggers yourself and run makemigrations again. Re-create them against the new schema afterwards if you still need them. If you meant to rename the model rather than delete it, run makemigrations interactively and answer its rename question: a rename on its own keeps its views and triggers on both engines. On SQLite, a rename in the same migration as a table rebuild is refused when a view or trigger that rebuild carries names the renamed table (see the rebuild check below); apply the rename as a migration of its own first. With interactive = false, name the rename with renames = ["old_table" => "new_table"] instead (see Automation & CI/CD). Without a hint it plans the drop, unless the new model's columns match the old table's exactly, in which case it refuses to guess.

Coming from Django?

Django's DeleteModel drops the table with CASCADE on PostgreSQL, so a view that reads it disappears along with it. PormG refuses instead, on every engine, so dropping an object it did not create is always your explicit step.

Statement Ordering

A migration's statements are applied in a fixed sequence of buckets, not in the order the plan file lists them:

  1. CREATE TABLE (new models)
  2. DROP TABLE
  3. RENAME TABLE
  4. RENAME COLUMN
  5. Everything else — column alterations, ADD CONSTRAINT, DROP CONSTRAINT, DROP INDEX
  6. Field CREATE INDEX

Within a bucket the order is stable but arbitrary — effectively alphabetical by table, because the plan is read back out of pending_migrations.jl by module binding name. It is not a dependency order, and PormG does not compute one.

That is safe rather than lucky, and it rests on three properties the test suite pins:

  • PostgreSQL and MySQL never inline a foreign key in CREATE TABLE. Every key is a separate ALTER TABLE … ADD CONSTRAINT in bucket 5, so it runs after every CREATE TABLE. Two new tables that reference each other therefore apply in either order — which no dependency sort could achieve, because that is a cycle.
  • DROP TABLE is DROP TABLE … CASCADE on PostgreSQL, so a parent can be dropped before its children are cleaned up. Because CASCADE also removes the children's constraints, PormG emits DROP CONSTRAINT IF EXISTS — otherwise removing a child's foreign-key field in the same migration that drops its parent would abort on a constraint the CASCADE had already taken. Those foreign keys are all the CASCADE is left to take: makemigrations refuses to drop a table while anything else depends on it (see Deleting a Model).
  • SQLite suspends foreign-key enforcement for the whole migration (PRAGMA foreign_keys = OFF, restored by renewing the connection afterwards). Its inline REFERENCES clauses therefore constrain nothing while DDL is running, and SQLite resolves an FK's parent table lazily in any case.
  • MySQL drops a table with foreign-key checks off (it has no CASCADE), so a parent can be dropped before its children's keys; each child's key is dropped by its own step in the same plan, and makemigrations refuses a key to it from a table the plan does not manage.

A renamed table is renamed before anything else touches it. When makemigrations asks whether a model with no table is a rename and you pick its former name, the plan holds ALTER TABLE "<old>" RENAME TO "<new>" in bucket 3, and every column change for that table is written against the new name — PormG reads the old table's live constraints and indexes at planning time, when the database still has nothing else. Tables whose foreign key points at the renamed one — including the renamed table itself, if it references itself — get no statement at all: both engines carry the constraint across a rename (PostgreSQL follows the table, SQLite rewrites the REFERENCES clause), so a rename on its own is not destructive. Because of this, makemigrations asks every table-rename question before any field-rename question. The exception is a table that also changes its key in the same migration, such as a different on_delete: that key is re-pointed at the new name, after the rename, and like any re-point it is destructive (see Changing a Foreign Key above).

Why there is no topological sort

This is the same design position as the rest of the engine: no dependency graph, no replay (see What this means in practice). The plan file is a flat, frozen v1 artifact whose statements are opaque SQL by the time they are executed, so ordering by dependency would mean changing the format rather than adding a sort. Keeping constraints out of the ordering problem is cheaper and handles cycles, which a sort cannot.

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, the PRIMARY KEY, or any index.

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 referencing a dropped column is not re-created (see the expression-index note below).
  • Drops the old table, renames the new one, and runs PRAGMA foreign_key_check to catch orphaned rows.
  • Keeps the table's triggers, and the views and other tables' triggers that name it: those that would block the rename are dropped first, and all of them are re-created afterwards (see the triggers-and-views note below).

The rebuild is emitted as plain DDL that composes with the migration's transaction, so no data is lost and the remaining indexes, the constraints your models declare, triggers and views are preserved. A CHECK written by hand is not one of them — clauses no model can declare are the exception, see the last note below. This is what makes removing a foreign-key field or constraint, a UNIQUE column, a PRIMARY KEY column, or an indexed 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, and not referenced by an index) — use ALTER TABLE directly, without a rebuild.

This process is transparent to the user but may take longer on very large tables.

Adding a foreign key to an existing table. When the column already exists — a db_constraint = false key flipped back on, say — the rebuild renders the FOREIGN KEY clause from your model, so the constraint really is created, and PormG logs an @info noting that the table is being rebuilt, because that cost is worth knowing about on a large table. Re-pointing a key that already exists takes the same rebuild but logs nothing.

A foreign key arriving as a new column is created too, and usually without a rebuild. SQLite accepts an inline REFERENCES clause on ALTER TABLE ... ADD COLUMN provided the column is nullable and has no default, which is the ordinary shape of a new ForeignKey:

ALTER TABLE "result" ADD COLUMN "circuitid" INTEGER NULL REFERENCES "circuit"("circuitid") ON DELETE CASCADE;

Give the column a default and SQLite will not take the clause inline — PormG then adds the column and rebuilds the table from your model, which renders the FOREIGN KEY clause the same way CREATE TABLE does. PostgreSQL and MySQL are unaffected either way: each adds a separate named constraint with ADD CONSTRAINT.

Two column shapes SQLite refuses outright

Independently of foreign keys, SQLite will not ADD COLUMN a UNIQUE column at all, nor a NOT NULL column without a default — the refusal is about the column. So adding a new OneToOneField (which is unique = true), or a new null = false field that ends up with no DEFAULT, fails on SQLite when the table already exists. It fails on the ADD COLUMN itself, before any rebuild PormG queued behind it, and the migration rolls back rather than doing anything silently.

DateTimeField and DateField are the exception to the second half: PormG synthesizes a temporary default for those two, adds the column with it, and then rebuilds the table to drop it — so a new required timestamp needs none of the below. That temporary default exists only for a null = false column with no default of its own. A null = true DateTimeField / DateField is added as a plain nullable column and every existing row stays NULL — no temporary value, no rebuild — and a column with a declared default backfills through that default and keeps it.

PostgreSQL accepts the UNIQUE column, but shares the NOT NULL restriction: ADD COLUMN … NOT NULL with no default is rejected on a table that already has rows, because the existing rows would violate it. MySQL would fill those rows with 0 or '' instead, so PormG refuses the plan there before it runs.

For every other required column, add it in two steps on any backend: declare it nullable with no default, migrate, backfill the values, then tighten it — the tightening is an alteration of an existing column, which takes the table rebuild on SQLite, an ALTER COLUMN on PostgreSQL and a MODIFY COLUMN on MySQL.

Deleting an indexed column, and the index shapes PormG cannot re-create

SQLite refuses ALTER TABLE ... DROP COLUMN for a column any index references, so deleting an indexed column takes the table rebuild rather than a plain DROP COLUMN. The rebuild drops every index with the old table and re-creates the ones the rebuilt table can still support, so the end state is the same — it just costs a data copy on a large table.

On SQLite PormG writes a plain CREATE INDEX (from db_index and Models.Index), a plain CREATE UNIQUE INDEX (from Meta.unique_together and many-to-many join tables), and a Models.Index with a descending column, an expression or a WHERE clause, which it marks /* pormg:index */ (/* pormg:index:<hash> */ for one holding SQL text). A marked index comes back from its declaration. An unmarked one of the shapes below was not created from a model declaration, so no declaration brings it back:

  • an expression index — CREATE INDEX ... ON t(lower(a))
  • a partial index — CREATE INDEX ... ON t(a) WHERE b > 0
  • an explicit COLLATE — CREATE INDEX ... ON t(a COLLATE NOCASE)
  • a sort direction without PormG's marker — CREATE INDEX ... ON t(a DESC); Django's Index(fields=['-name']) produces exactly this. A Models.Index(fields=("-a",)) declaration can express it, but did not create it.

A Models.Index declaration can express all four, but an unmarked one did not create it: a database adopted through generate_models_from_db or the Django importer can arrive carrying any of them, and so can one indexed by hand.

When a rebuild drops one of those four, PormG logs a warning naming the index and its definition, so an index PormG did not create is never dropped silently. Nothing puts it back, though: re-create it by hand after the migration if you still need it. A marked index is dropped without a warning, like a plain one — its declaration decides whether it comes back. (A declaration whose text names a column the migration removes is refused before that, by makemigrations.)

┌ Warning: SQLite table rebuild will DROP an index PormG cannot re-create: it uses an
│ expression, a WHERE clause, an explicit COLLATE or a sort direction, and carries no
│ pormg:index marker, so no model declaration created it and none will bring it back.
│ Re-create it by hand after the migration if you still need it.
│   table = "driver"
│   index = "driver_surname_lower_idx"
│   dropped_columns = 1-element Vector{String}: …
│   definition = "CREATE INDEX \"driver_surname_lower_idx\" ON \"driver\" (lower(\"surname\"))"

A plain index — the two shapes PormG does write — is dropped without a warning, because the column it covered is the one you removed: a db_index you still declare comes back with the rebuild, and a unique_together group you still declare cannot name a column that no longer exists.

Such an index on a column that survives the rebuild is preserved, name and all — across a RENAME COLUMN too: the renamed column is rewritten inside the preserved index's DDL wherever it appears, however the index spells it (lower(surname), lower("surname"), [surname]), so a hand-written expression or partial index follows the rename.

Triggers and views across a rebuild

Dropping the old table drops every trigger on it, and renaming the new table into place fails if a view — or a trigger on another table — names it. PormG follows SQLite's own procedure for table changes: those views and triggers are dropped before the rebuild, and they and the table's own triggers are re-created after it, from their definitions as they stand when makemigrations runs. A view built on one of those views comes along, and so does an INSTEAD OF trigger on it. None of them has to be declared in your models.

A definition comes back exactly as written, with one exception. When the same migration renames a column of a trigger's own table, the references PormG can prove belong to that table follow the rename: NEW.points, OLD.points, result.points, and the column list of UPDATE OF points.

Otherwise, a definition that still names something the migration takes away fails makemigrations loudly, naming the view or trigger, and no plan is written:

  • a column the rebuild removes, or a table the migration drops (reported as the drop's own refusal, since that check runs first: see Deleting a Model) — including a removed column read through * where the number of columns matters: a UNION, an INSERT … SELECT *, or a column list (a plain SELECT * FROM result view simply narrows, and comes back);
  • a column or table the migration renames, anywhere but the references above — a view selecting the renamed column, say, or a bare UPDATE result SET … in a trigger on a table being renamed.

PormG follows a column through its table's name, through a view built on the table (SELECT grid FROM result_v, where result_v is SELECT * FROM result), through NEW/OLD in an INSTEAD OF trigger on such a view, and past an alias or a CTE that reuses a table's name. The check reads names, not SQL scope, so where it cannot tell which table a column belongs to it refuses rather than guesses.

SQLite would accept such a definition without a word and then refuse every later ALTER TABLE … RENAME in the database, so putting it back would be worse than stopping. To get through, apply the rename as a migration of its own first — SQLite's RENAME then rewrites the definition itself, and the next makemigrations reads the rewritten one — or drop the view or trigger, migrate, and re-create it. A token scan cannot resolve scope, so when another table in the same definition has a column with the removed column's name, PormG refuses rather than guess which one is meant.

A pending_migrations.jl written by an earlier makemigrations is left as it was when this one fails.

Clauses no model can declare do not survive a rebuild

The rebuilt table is rendered from your model, so whatever its CREATE TABLE carries that no field or model option expresses is gone afterwards:

  • a hand-written CHECK, on a column or on the table — PormG's own >= 0 and byte-length checks are modelled, and kept, and so is every CheckConstraint the model declares. Declare a hand-written CHECK under its own name to keep it;
  • a column COLLATE, a generated column, or an ON CONFLICT clause;
  • a composite FOREIGN KEY, and a key's ON UPDATE, DEFERRABLE or MATCH. A SQLite database created by Django declares every key DEFERRABLE INITIALLY DEFERRED, so an imported schema reports it;
  • the table options STRICT and WITHOUT ROWID.

makemigrations logs one warning per rebuilt table, quoting each such clause, so none is dropped silently. Re-apply by hand after the migration whatever you still need; a table option can only come back by rebuilding the table yourself.

┌ Warning: SQLite table rebuild will DROP clauses no model declaration can express: the table is
│ re-created from its model, which cannot hold them. Re-create them by hand after the migration if
│ you still need them.
│   table = "result"
│   clauses = 1-element Vector{String}: …
Dropping a primary key: per engine

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, and MySQL's does the same (the plan is one DROP COLUMN, destructive). 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 SQLite and alters in place on PostgreSQL; on MySQL the key moves inside the column's ALTER TABLE (DROP PRIMARY KEY, ADD PRIMARY KEY).

SQLite: serialized by the write lock, not an advisory lock

SQLite has no advisory lock. migrate() applies the plan inside BEGIN IMMEDIATE, and checks whether it is already applied inside that transaction, so two processes migrating one file apply a plan at most once: the second waits for the first, then reports :already_applied. The wait is SQLite's 30-second busy timeout rather than lock_wait, and none of it holds on a network filesystem. Details: Deploying → SQLite.

MySQL: a named lock

migrate() takes MySQL's named lock (GET_LOCK) under the same key. MySQL lock names are server-wide, so PormG hashes the database name into it, and two databases on one server migrate independently. Like the advisory lock it is session-level, so the transaction-pooling warning below applies. Details: Deploying → MySQL.

PostgreSQL: Advisory Locking

migrate() acquires a PostgreSQL session-level advisory lock before it writes anything — the history table and the configured extensions included — so a second migrator against the same database queues instead of interleaving its DDL. It waits up to lock_wait seconds (default 30) and then fails with an OperationalError naming the process that holds the lock, rather than proceeding unserialized. run_once data steps take the same lock. Running migrate() from several instances at boot: Deploying.

The key is the constant pormg::migrations, with no database or folder qualifier — deliberately. A PostgreSQL advisory lock is tagged with the database OID alongside the key, so the database is already the lock's namespace:

  • Every configuration pointing at one database contends on one lock, including two different db/ config folders that resolve to the same server and database. Before #90 the key embedded the config folder name, so those two folders took two different locks and migrated one database concurrently.
  • Two databases cannot collide on it, however identical the key, because their locks carry different database OIDs.

This is a guarantee about one database, not one server: migrate() against analytics does not block migrate() against billing on the same cluster, which is what you want.

Two PormG versions can take different keys

Versions before this change embedded the config folder name in the key. While a rolling deploy has some instances on either side of it, the two take different keys and therefore do not exclude each other — transiently the very condition this fixes. Nothing in an app's source has to change; just avoid running migrate() from two PormG versions at once.

A transaction-pooling proxy defeats it

The lock is session-level — it lives on the connection that took it. Behind PgBouncer in transaction mode (or any pooler that reassigns server connections per transaction) the lock can be released or observed on the wrong backend. Point migrate() at a direct connection, or use session pooling.

PostgreSQL: Identity Columns

IDField() renders a PostgreSQL identity column, and generated_always = true makes it the stricter GENERATED ALWAYS AS IDENTITY — a column application code cannot supply a value for. Changing that declaration is a migration like any other, and PostgreSQL spells the three transitions differently:

ChangeStatement PormG emitsOrder
A non-identity column becomes oneALTER COLUMN "id" ADD GENERATED { ALWAYS | BY DEFAULT } AS IDENTITYafter the type change — a column can only become an identity once it is already an integer type
The flavour moves, BY DEFAULT ⇄ ALWAYSALTER COLUMN "id" SET GENERATED { ALWAYS | BY DEFAULT }unordered — it does not touch the column type
An identity column stops being oneALTER COLUMN "id" DROP IDENTITYbefore the type change — PostgreSQL enforces the integer restriction during ALTER COLUMN ... TYPE, so a later DROP IDENTITY would never run

The distinction between the first two matters because PostgreSQL rejects the wrong one: ADD GENERATED on a column that already is an identity fails with column "id" is already an identity column. Tightening a live key is therefore SET GENERATED ALWAYS, and it changes only the flavour — the sequence keeps its current value and no data is rewritten.

SQLite and MySQL have no equivalent. SQLite's identity is INTEGER PRIMARY KEY AUTOINCREMENT, which no ALTER can change, and MySQL's is AUTO_INCREMENT. Neither has an ALWAYS/BY DEFAULT distinction, so generated_always is a no-op on both, and a change to it correctly plans nothing.