Skip to content

Migrations & Upgrades

You change your models; you never write a migration. Running fullfinity-server -u all upgrades a database across any number of versions safely (it never loses data and never half-applies) and painlessly (no hand-written migration scripts for the common case, and custom modules survive core changes).

The guarantee is mechanical, not a matter of discipline: the schema diff is incapable of destroying data, a gate blocks silent breaking changes before they merge, and every upgrade runs in one transaction. (For the full rationale, see MIGRATION_UPGRADE_DESIGN.md at the repo root.)

A single -u all runs one atomic transaction — everything below commits together or rolls back together, so a failure never leaves a half-migrated database:

  1. Additive schema sync. New columns, tables, and indexes are added; safe in-place ALTERs are applied. The diff never drops a column or table — so a field that merely disappeared from the code can’t silently destroy data.
  2. Ledger replay. Recorded schema changes are applied in order, per installed module: a rename copies the old column’s data to the new column, then drops the old column; a delete drops its column; a complex change runs its transform hook.
  3. Commit (or roll back the whole thing on any error).

So a rename neither loses data nor leaves clutter behind: it carries, then drops, in the same command. There is no separate “cleanup” step to run.

Reads during an upgrade tolerate the old schema

Section titled “Reads during an upgrade tolerate the old schema”

An upgrade necessarily runs against a database whose schema is behind the code — that is what it is there to fix. But the upgrade is itself an application that reads records (the module list, the system user, category and view records), and an ORM read normally selects every column the model declares. On a database that predates a newly-declared field, such a read names a column that doesn’t exist yet.

So for the duration of a module operation, reads select only the columns the database actually has. This is automatic and covers the whole operation, first statement to last — you don’t opt into it, and adding a field to any model, User and Group included, needs no special handling: declare it and run -u all.

This matters more than it looks, because Postgres aborts the entire transaction on the first failed statement. One untolerated read would take the upgrade down and every statement after it would fail with current transaction is aborted — naming whichever innocent statement ran next, not the one at fault. If you ever do see that error, the message now carries the originating statement and its real error alongside it; that first error is the only one worth reading.

The additive sync is also self-healing against a stale table. If a physical table survives from an earlier failed or rolled-back install while the framework’s metadata considers the model new, the sync no longer trusts CREATE TABLE IF NOT EXISTS to be enough — it detects the pre-existing table and reconciles its columns to the model (adding any that are missing, still additive-only). So a leftover table can never leave a newer column absent and crash the next query on it; the next -u all (or module install) brings the physical schema back in line.

-u all targets one database, named with -db. On a multi-tenant deployment you can instead pass --all-databases (alias --all-dbs) to run the same operation against every Fullfinity database this deployment owns — discovered automatically, no list to maintain:

Terminal window
fullfinity-server -c config.yaml --all-databases -u all
  • --all-databases and -db are mutually exclusive — a missing -db still errors rather than silently upgrading everything, so the fleet-wide run is always a deliberate choice.
  • Disposable databases are skipped. Ephemeral test databases and hand-made dev_scratch_ scratch databases are never fleet-upgraded.
  • Each database upgrades in its own atomic transaction. A failure on one database is reported and the remaining databases still upgrade; a per-database pass/fail summary is printed at the end, and the command exits non-zero if any database failed.

The same flag works for any module operation (--install, --update/-u, --uninstall, --init, --update-module-list) and for the upgrade alias.

Uninstalling a module drops what it brought

Section titled “Uninstalling a module drops what it brought”

The additive-only rule protects code changes — an ambiguous diff must never destroy data. A module uninstall is different: it’s an explicit, intentional removal, so it drops the schema the module brought in. On uninstall the framework drops

  • the tables of models the module owns, and
  • the columns it added to other modules’ tables via __inherit__ (a bridge’s contributions),

inside the uninstall’s transaction, then updates the parent modules to their un-extended state. This keys on field.module / modelregistry.module — each field is attributed to the module that introduced it (a base declaration, or the __inherit__ extension that added it), so a bridge’s columns are removed with the bridge, not left orphaned. Reinstalling recreates them. No schema-change ledger entry is involved — the ledger records code history; an uninstall is a runtime action whose safety net is the transaction plus the pre-op snapshot.

Selection choices that stop being offered are reconciled for you

Section titled “Selection choices that stop being offered are reconciled for you”

A Selection’s choice set is composed from the base declaration plus every installed module’s _selection_add. That set can shrink three ways, and every one of them leaves rows holding a value the field no longer declares — the next write of that field fails validation, with no legal value the user can pick instead:

  • a module contributing choices is uninstalled;
  • the base declaration is edited in code, dropping a choice it used to offer;
  • a module is installed whose composition narrows the set some other way.

You do not write a migration for any of them. The composed set is persisted onto the field registry, and each upgrade compares the freshly composed set against it. Whatever is missing is reconciled by the policy the contributing module declared with _selection_ondelete — cleared, or moved to a named replacement. The policy is read from the stored baseline rather than from the class, because the module that declared it is usually the one that just left.

Detection deliberately compares outcomes rather than watching module operations: the case that strands data most often is a base declaration edited in code, where no module is installed or uninstalled at all and a lifecycle hook would never fire. A database upgraded from before the baseline existed has nothing to diff against, so on that one upgrade the stored data itself is scanned and any value the field no longer offers is reconciled the same way.

A required Selection with no recorded policy is the one case left untouched: blanking it would swap one validation failure for another, and choosing a replacement would be a guess about business meaning. Those rows are reported in the upgrade log. Contributing a choice to a required field without declaring a policy is refused when the registry is composed, so this can only arise for a value that predates the rule.

There is no way for a module to remove a choice from another module’s field — see Selection Field Inheritance.

Existing stored fields may not be renamed, retyped, or removed without recording the change — and, because a Selection stores its choice text verbatim (the value shown in the dropdown IS the value written to the column), an existing Selection choice may not be renamed or removed without recording it either. Adding a choice is free (a wider dropdown, no data impact); renaming or dropping one orphans every existing row that holds the old value, so it goes through the ledger like a field change. This is all enforced by a static gate, so you can’t ship a silent break:

Lengthening a column is additive. Growing a field’s max_length — a wider Char, or renaming a Selection choice to a longer string — is a safe in-place VARCHAR(n) → VARCHAR(m>n) that -u all applies automatically; the gate treats it as additive, not a retype. Only a shrink (a smaller max_length, which could truncate or reject stored values) is breaking and must go through resolve. (A Selection-choice rename still records a rename_selection entry to carry the stored text — that’s the value change, independent of the column width.)

Terminal window
fullfinity-server check --only schema

check --only schema composes the field surface from code (no database needed) and diffs each module against the committed baseline that lives inside that module (<module_dir>/schema_baseline.json). It exits non-zero if any existing field a module owns was removed, renamed, or retyped, if an existing Selection choice was renamed or removed — or if a complex change ships without a fixture test. It runs in CI and as a pre-commit hook:

Terminal window
git config core.hooksPath .githooks # enable the local pre-commit gate

You never have to remember any of this — if you rename a field, the gate fails the build and tells you to resolve it.

Trustless across releases. The committed schema_baseline.json is what the gate diffs against within a working tree — but a baseline is a file a change can re-snapshot, so the gate also runs a second, trustless comparison keyed on the immutable git release tag. Two checks, both reading the state at the last release tag (which an ordinary commit can’t rewrite):

  • Surface — it reads each module’s baseline as it shipped (git show vX.Y:<module>/schema_baseline.json) and requires every field, model, or Selection choice that was in that release but is gone now to be named by a ledger entry (delete/rename/complex/delete_model/…). So resolve --snapshot-ing the baseline to hide a removal no longer passes — the removal is caught against the tag.
  • Ledger — it reads the ledger as it shipped (git show vX.Y:<module>/schema_changes.yaml) and requires it to be a prefix of the current ledger: every released entry still present and byte-identical, only new higher-id entries appended. Deleting or editing a past entry (which would silently break -u all replay for anyone upgrading from an older version) fails the build.

The view-anchor and report-anchor gates use the same release-tag ratchet (view_anchor_baseline.json, and the report bodies themselves). Before the first release tag exists there is no frozen surface, so pre-release the surface churns freely.

A ledger id, though, is protected from the moment it exists — tag or no tag. The release ratchet above only speaks at tagging time, and databases are upgraded from the tree continuously, so an id can be applied to a real database and then deleted again entirely inside one release window. That deletion is not a tidy-up; it is a break, and it lands on whoever already ran it:

A database’s replay clock is a high-water mark, not a set of applied ids. Meet a module whose highest id is lower than the mark and the upgrade reads as a downgrade and refuses — not just that module, the entire run. The database stops upgrading until the entry comes back.

So each module’s baseline records every ledger id it has ever carried (change_ids in schema_baseline.json), and check --only schema fails while any recorded id is missing from schema_changes.yaml. That record is append-only too: resolve --snapshot unions ids rather than replacing them, so re-snapshotting cannot quietly forget an id you just deleted.

If a migration turns out to be unnecessary, leave the entry in place. Replay skips what a database has already done, and a hook that is a no-op costs a fresh install nothing.

What the guarantee rests on. The gate logic is trustless — no commit that passes it can hide a breaking change. But a release-tag ratchet is only as immutable as the reference it reads, so the guarantee assumes three repo-governance controls (configure these once; no gate can enforce them from inside the code):

  1. Release tags are created only on CI-green commits, and tag creation is restricted. The tag is the reference; if an arbitrary or unverified commit is tagged, the reference is dishonest.
  2. Tags and the main branch are protected from force-push / history rewrite. A trustless ratchet assumes its reference history can’t be moved.
  3. CI runs as a required, server-side status check — not just the local .githooks pre-commit (which --no-verify bypasses). The check must be un-skippable on the protected branch.

With those in place the chain is complete: the surface and the ledger can’t change a released contract without a recorded, replayable entry, and the record itself can’t be rewritten. A malicious-but-recorded change (e.g. a deliberate delete) still surfaces at the customer’s upgrade via preview --db — accountable, never silent. If you run your own checkout with custom modules, the same three controls make the guarantee hold for your modules’ baselines and ledgers.

Per-module ledger & baseline (and third-party modules)

Section titled “Per-module ledger & baseline (and third-party modules)”

Both the change ledger and the baseline are per-module files that travel inside the module’s own directory:

FileWhat it is
<module_dir>/schema_changes.yamlthis module’s append-only change ledger (ids monotonic within the module)
<module_dir>/schema_baseline.jsonthis module’s committed field-surface contract (only the fields/models it owns)

A field a module adds to another module’s model via __inherit__ is attributed to the adding module, so it lives in that module’s baseline — renaming or deleting it is checked against your contract, not the model owner’s. Because each module carries its own files:

  • Third-party modules work. A module distributed through an app store records and ships its own sanctioned breaking changes. Its ids never collide with another publisher’s (they start from 1 within the module), and the engine resolves its baseline/ledger relative to wherever the module is installed (Module.path) — not a hardcoded path — so a customer’s folder layout is irrelevant.
  • Enforcement reaches modules that skip your CI. Third-party publishers don’t run your CI, so the gate also runs at upgrade time on the customer’s machine: -u all refuses to upgrade a non-first-party module whose shipped code diverged from the baseline shipped in its own package with no recorded migration — or that ships a stored surface but no baseline at all (silent absence → a loud ConfigurationError). For the app store itself, run check --only schema --module <id> on each submitted package at intake.

Scope any of the three commands to one module with --module <identifier>:

Terminal window
fullfinity-server resolve --snapshot --module acme_billing # (re)write just that baseline
fullfinity-server check --only schema --module acme_billing # gate just that module (intake)

The first-party product baselines stay stable no matter what custom modules a checkout contains — partitioning isolates each module’s surface, so a custom module can never shift core’s baseline.

The version bump is enforced (the check --only version-bump gate)

Section titled “The version bump is enforced (the check --only version-bump gate)”

The append-only gate guarantees a breaking change is recorded; a second release-time gate guarantees the release is labelled correctly. Because every change is already recorded — a break as a ledger entry, an addition as new surface in the baseline — the minimum legal SemVer bump is derivable, not a human’s guess:

Change since the last releaseMinimum bump
bug fixes only, no schema changepatch
new fields/models (additive), or a data backfillminor
any delete/rename/retype/Selection change/complex/removed view anchor/removed modulemajor

At release time (a pushed vX.Y.Z tag), check --only version-bump composes the field surface in-process, reads the previous release tag’s baselines and ledgers via git show, classifies the delta, and refuses the tag when:

  • the tag disagrees with the code’s own __version__ (the single source of truth in fullfinity/engine/__init__.py, kept in lockstep with fullfinity.__version__),
  • the version is a downgrade from the previous release, or
  • the bump is smaller than the change class demands (e.g. a release that drops a column tagged as a patch).

This is what lets an auto-updater trust the label: a patch can never contain a migration, so it is safe to apply without the schema-change ceremony a minor/major needs. The gate runs only at release time (commits on main don’t move the version), keyed on the immutable release tag — the same trustless reference the append-only checks use. It does not cover pure code-level API breaks (a changed method signature, a reshaped REST payload) — those touch neither the ledger nor the baseline and remain a manual judgement.

When the gate flags a change, record it:

Terminal window
fullfinity-server resolve # interactive
fullfinity-server resolve --assume-renames # auto-record obvious renames

For each flagged field it asks one question — rename, delete, or complex — and for a flagged Selection choice it asks rename-value or complex. It writes an entry to the change ledger (schema_changes.yaml), then re-snapshots the baseline so the gate goes green:

An interactive prompt is useless to an agent, CI job, or a third-party publish pipeline — there’s no TTY to answer it. Declare the intent as data instead; each flag is repeatable:

Terminal window
fullfinity-server resolve \
--rename SaleOrder.partner=customer \ # field rename: carry data, drop old column
--delete SaleOrder.legacy_note \ # sanctioned field deletion
--complex Invoice.amount=mod.path:fn@tests/fixtures/amount.py \ # transform: hook@fixture
--rename-choice SaleOrder.state:Quotation=Draft \ # Selection choice rename/fold: carry values
--clear-choice SaleOrder.state:Legacy \ # NULL the rows on a removed choice
--complex-choice Invoice.state=mod.path:fn@tests/fixtures/state.py \ # custom remap of orphans
--rename-model OldName=NewName \ # model/table rename
--delete-model DeadModel # sanctioned model deletion

Resolution order is: an explicit flag → --assume-renames (obvious single-candidate renames) → interactive prompt (TTY only). Non-interactive resolution is atomic: if any flagged change is left undeclared, the command records nothing and exits non-zero, listing exactly what to declare — so a forgotten decision fails loudly instead of silently half-resolving (which would either bake an undeclared removal into the baseline or duplicate a ledger entry on re-run). This is the path Claude/CI use; the same check --only schema gate still verifies the result.

  • rename → the new field name; replay will carry the data.
  • delete → the column is dropped on upgrade (the pre-upgrade backup + the atomic transaction are the safety net).
  • rename-choice → carry every row that held the old choice text to a new value: replay runs UPDATE … SET field = new WHERE field = old. Deterministic, no hook. The target only has to be a current choice, so this also folds a removed choice’s rows into a surviving one (e.g. --rename-choice Order.state:Cancelled=Draft).
  • clear-choice → for a removed choice whose orphaned rows should become NULL rather than fold into another value: replay runs UPDATE … SET field = NULL WHERE field = old. Also deterministic and hook-free (records a clear_selection entry).
  • complex-choice → when the orphaned rows need a conditional / row-specific disposition (split by another column, delete the rows), supply a transform hook + fixture (as with complex). One hook covers the whole field.

There is deliberately no “remove the choice and leave the rows as-is” option — that is the silent-orphan bug this gate exists to prevent. Every removal must state where its rows go (fold, NULL, or a hook).

  • delete-model → the table is dropped on upgrade. Replay tries a plain DROP first, so a surviving table that still references the model surfaces as a hard error rather than a silent constraint drop. When you delete several related models together (e.g. a parent and its child line), record them in any order: if the ledger happens to drop the parent before the child whose foreign key points at it, replay retries that one drop with CASCADE — which on a DROP TABLE only removes the dependent constraint, never the child table’s rows, and the child is dropped by its own entry moments later.
  • complex → a transform hook (module.path:fn) plus a fixture test that proves it. The gate refuses a complex change without its fixture.

There is also a data entry for a one-time data pass that isn’t triggered by a schema change (a backfill, a storage reshape) — a hook (module.path:fn, e.g. in modules/<m>/migrations.py) + a fixture, replayed once per DB via the id clock. This is the home for what used to be a post_upgrade hook. There is one mechanism: the version-keyed upgrades/v{X}_{Y}.py / pre_upgrade/post_upgrade path has been retired, and fullfinity-server upgrade is just an alias for -u all.

A complex hook is async def fn(env) that replay calls inside the upgrade transaction. For the common cases — changing a column’s type, or remapping values / a foreign key — use the set-based helpers in fullfinity.engine.migration_helpers (they scale to large tables and are one line, instead of hand-written DDL):

# invoicing/migrations.py (a complex/data ledger entry references it as "module.path:fn")
from fullfinity.engine.migration_helpers import retype_column, remap_column
async def quantity_to_integer(env):
# Change a column's type, transforming existing values (ALTER ... TYPE ... USING).
await retype_column(env, "InvoiceLine", "quantity", "integer",
using="round(NULLIF(quantity, ''))::integer")
async def retarget_partner_fk(env):
# Re-map a foreign key by matching a natural key (set-based UPDATE).
await remap_column(env, "Order", "partner",
using="(SELECT id FROM contact c WHERE c.legacy_ref = \"order\".partner_legacy)")

Then record it and write the fixture:

Terminal window
fullfinity-server resolve # choose 'complex', point at the hook + a fixture test

The fixture seeds representative rows, runs the transform, and asserts the output — the input→output oracle the gate requires. (See fullfinity/modules/core/tests/test_schema_replay.py for worked examples of both helpers.)

using/where expressions are interpolated into SQL and must be trusted, code-authored — never user input.

A scalar and a relation don’t share a column: a Char named service is stored as service, while a ManyToOne named service is stored as service_id. So changing a field between the two is not an in-place type change, and your hook must not try to retype_column it.

The upgrade adds the new column (with its index and foreign key) in the additive pass and leaves the old one in place, so by the time your hook runs both columns exist. Read the old one, write the new one, then drop the old one yourself:

async def service_codes_to_records(env):
conn = env.conn
rows = await conn.fetch(
"SELECT id, service FROM shippingmethod WHERE service IS NOT NULL AND service <> ''")
for row in rows:
record, _ = await get_model("CarrierService").get_or_create(code=row["service"])
await conn.execute(
"UPDATE shippingmethod SET service_id = $1 WHERE id = $2", record.id, row["id"])
await conn.execute("ALTER TABLE shippingmethod DROP COLUMN IF EXISTS service")

Guard the read on the old column still existing (information_schema.columns) so the hook is a no-op on a database created after the change, and record it with resolve --complex like any other transform.

If your hook runs its own DDL, invalidate the statement cache

Section titled “If your hook runs its own DDL, invalidate the statement cache”

The database driver caches a prepared plan per query text, and Postgres invalidates the plan of any query whose table changed shape — reported on the next run of that same query text. Outside a transaction the driver quietly re-prepares and retries; inside one it cannot, and the error aborts the whole upgrade. Replay runs your hook inside the upgrade transaction, so a hand-written ALTER/DROP followed later by a query against that table can take the upgrade down with cached statement plan is invalid.

The helpers above already handle this. If you issue DDL yourself, call the invalidation right after it:

from fullfinity.engine.db import invalidate_statement_cache
async def add_a_partial_index(env):
await env.conn.execute("CREATE INDEX CONCURRENTLY ... ") # your own DDL
await invalidate_statement_cache(env.conn) # shed the stale plans

It is cheap and idempotent — calling it after every DDL statement is the correct habit. Pure data changes (UPDATE/INSERT/DELETE, including remap_column) don’t need it; only statements that change a table’s shape do.

Each module’s ledger is keyed by a monotonic change id (within that module), not a version — so a field renamed several times across many commits replays correctly, and a database jumping many versions at once applies exactly the changes it’s missing, in order. Each database tracks how far it has applied per module (Module.schema_applied_id, a column on the installed-module row), so changes are never re-applied, and a module that isn’t installed simply skips its entries. The same per-module mark is what makes many databases on one server work cleanly: they share the module’s one ledger file but each advances its own mark independently.

Replay only ever moves a database forward from where it already is. A brand-new install is already at the head: every table is built directly at its current shape, so there is no old column to rename and nothing to drop. The newly installed module’s mark is set to the ledger’s latest id, and the historical entries — which describe a journey from shapes this database never had — are skipped (a recorded rename or delete is a no-op when the old column was never there). So renames and deletes only ever run against databases installed at an earlier id that are now catching up. An install that never saw the old shape applies none of them.

The append-only rule binds a ledger entry only once it has shipped and another database has replayed it — at that point its id is immutable history that other databases are counting on. Before that line, the ledger and baseline are ordinary tracked files in your working tree, and a mistaken entry is undone like any other uncommitted change.

So if you record a rename or delete you didn’t mean — or you add a field and remove it again within the same unshipped change — do not record a second entry to “correct” the first. Revert to net zero instead:

Terminal window
git checkout -- fullfinity/modules/<m>/models/<model>.py \
fullfinity/modules/<m>/schema_changes.yaml \
fullfinity/modules/<m>/schema_baseline.json

This restores the model, drops the unwanted ledger entry, and rewinds the baseline together, so check --only schema sees no breaking change and there is nothing left to replay. Revert the baseline with it — it carries the record of which ids have existed, so dropping the entry alone leaves the id recorded and the gate red (which is the point: that is indistinguishable from withdrawing an id someone has already applied). (If your own dev database already applied the bad change and dropped a column, re-adding the field is a free additive change — -u all recreates and backfills the column on the next run.)

Two things this rests on:

  • The gate that nags you during development is the commit-time onecheck --only schema, which composes the field surface from code and diffs it against the committed baseline. It fires whether or not any database exists, so the way to clear it is to make the diff net-zero, not to satisfy it with a deletion entry you don’t want.
  • The ledger only has teeth against databases catching up from an earlier id (see A fresh install replays no history above). A module that has never shipped has no such databases — other than possibly your own dev DB — so a bad entry caught before release costs nothing.

Whole-model changes are handled the same way as fields, one level up. The gate detects a model removed from the code (or a table rename) and routes it through resolve:

  • rename a modelrename_model: replay runs ALTER TABLE old RENAME TO new. The data and every foreign key pointing at the table follow automatically — no copy needed. (If the model’s _table_name is explicit and unchanged, only the Python name changed and there’s nothing to do at the DB level.)
  • delete a modeldelete_model: the table is dropped (plain DROP, not CASCADE — if other tables still reference it the upgrade fails and rolls back, surfacing the dependency rather than cascading loss).
  • split / merge → resolve as complex: a hook apportions or combines rows across tables (use remap_column and raw INSERT ... SELECT as needed), with a fixture.

resolve suggests a rename target by matching the removed model’s field set against newly-added models. As with fields, the diff itself never drops a table — model deletes happen only through a recorded ledger entry.

Moving a model (or field) to another module

Section titled “Moving a model (or field) to another module”

Splitting a feature out — e.g. relocating a stored model, or a field added via __inherit__, from module A into a new module B that declares the identical table and columns — is not a delete. Because migration is additive-only and the physical table is unchanged, there is nothing to drop or copy: the data stays put and simply changes owning module. Do not record a delete_model (or delete) — that would DROP the table on replay and lose the data.

Instead, re-snapshot both baselines in the same change and record no ledger entry:

Terminal window
fullfinity-server resolve --snapshot --module B # new module: table is now B's (additive)
fullfinity-server resolve --snapshot --module A # A's surface no longer includes it

The gate then passes because A’s regenerated baseline matches A’s code, and B owns the surface. On every existing database -u all is a no-op for those tables (they already exist, additive-only leaves them in place), so the data survives — including a column A declared on a shared table (e.g. a SaleOrder FK) that B now re-declares. If B is gated behind a toggle and isn’t installed yet, the orphaned table/column simply persists until B is installed and adopts it. This is the one model/field change that is not a resolve entry: same-name, same-shape, additive on both sides.

Required fields whose default depends on install-hook data

Section titled “Required fields whose default depends on install-hook data”

Adding a required=True field with a default is normally free: the migration adds the column nullable, backfills every existing row from the default, then enforces NOT NULL. That assumes the default can produce a value while the migration runs.

Some defaults can’t. When a bridge module adds a required field to an already-populated model and that field’s default resolves to records the module only creates in its post_install/post_update hook, the prerequisite rows don’t exist yet at migration time. The canonical case: installing inventory onto a DB that already has sales orders adds SaleOrder.warehouse (required), whose default is the company’s default Warehouse — but that warehouse is created by inventory.post_install(), which runs after the migration.

The framework handles this automatically — you don’t write anything special:

  1. The migration backfills what it can. If a deferred-NOT NULL column still has NULLs, it is stashed instead of failing the migration (no NotNullViolationError).
  2. Install/update hooks run (post_install creates the warehouses).
  3. A finalize pass re-runs the backfill — the default now resolves — and then adds NOT NULL. Existing rows (including demo data) end up populated, not empty. If a row is still NULL afterwards, finalize raises and the whole install rolls back: a genuinely unsatisfiable required field fails loudly rather than shipping bad data.

For this to backfill correctly on a multi-company DB, write the default so it derives from the record’s own data (e.g. self.company’s warehouse) rather than the current user’s context — the backfill iterates existing rows, where “current user” is meaningless.

Most upgrade operations are metadata-only and instant regardless of row count: adding a column, dropping a column, and renaming a table (ALTER TABLE … RENAME, used for model renames) don’t touch rows. The one operation that does touch rows is a field rename’s data carry — a single set-based UPDATE to copy the old column into the new one, then a metadata-only DROP. On a table with millions of rows that UPDATE is the cost (minutes + WAL, inside the upgrade transaction — fine for a maintenance window).

Type changes (ALTER COLUMN TYPE) rewrite the table and are inherently heavy at scale — they go through complex hooks, where the author can choose a batched or online strategy.

Because core never removes the fields a custom module depends on (the gate enforces it), and renames are recorded and replayed, a custom module’s references keep resolving across upgrades. View inheritance is anchored to field names and stable element anchors, so a reflowed core layout doesn’t orphan an extension.

A data/complex ledger entry names two things — a hook (the code that runs) and a fixture (the test that proves it). They are resolved by two different mechanisms, and both must reference only your own module — never a path specific to where a given customer installed it. Every customer mounts custom modules under a different absolute module path, so anything customer-specific would break on the next install.

  • hook: <import.path>:<fn> is a Python import path handed straight to importlib.import_module. A custom module is loaded by putting its parent directory on sys.path and importing it by its own folder name (basename), so the hook’s package is just your module’s name — regardless of where it lives on disk:

    hook: my_module.migrations:backfill_widgets # my_module/ is the module folder

    Not the customer’s module path, and not fullfinity.modules.… (that prefix is only for modules bundled inside the framework package). The module folder name must be globally unique across all installed modules — it becomes a top-level importable package — which the unique-module-identifier rule already guarantees.

  • fixture: <file/path.py> is a filesystem path, resolved against your module’s own directory (and its parent), so make it module-relative:

    fixture: tests/test_backfill.py # <module_dir>/tests/test_backfill.py

    The gate only checks the file exists (it forces you to ship an example alongside a hand-written hook); it does not execute it here. The same existence check runs at your check --only schema time and at the upgrade-time self-check on the customer’s install, where your module directory is present.

Most friction with the system is one of a handful of cases. Each has a mechanical fix — you never hand-write SQL or edit the baseline by hand.

check --only schema fails on a field I didn’t consciously change. A refactor renamed or dropped a stored field, or moved one between modules. The gate is working — it caught a breaking change. Run resolve and declare what it is (rename / delete / complex), then commit the ledger entry and re-snapshotted baseline with your model change. A field added to another module’s model via __inherit__ is attributed to your module, so renaming it is checked against your baseline — declare it there.

check --only schema still fails after I reverted the model. Your baseline drifted out of sync with the reverted code. Don’t add a ledger entry to paper over it — rewind the baseline instead: git checkout the model, schema_changes.yaml, and schema_baseline.json together (see Backing out a change before it ships), or regenerate a clean baseline with resolve --snapshot --module <id> if the field really is gone.

-u all refuses: “ships a stored surface but no schema_baseline.json”. A module owns stored fields but never snapshotted its baseline, so the upgrade-time gate can’t tell sanctioned changes from silent breakage. Generate it once and commit it: fullfinity-server resolve --snapshot --module <id>.

resolve rejects a complex or data entry: “missing fixture”. Those actions run a hand-written hook whose correctness is only checkable by example, so the gate requires a fixture test that proves the transform’s input → output. Write the fixture (see Writing a complex transform) and point the entry at it; the gate refuses the entry until the fixture exists on disk.

Non-interactive resolve recorded nothing and exited non-zero. Resolution is atomic: if any flagged change is left undeclared, the command records none of them rather than half-resolving. Read the list it printed and declare every flagged change (--rename / --delete / --complex / --rename-choice / --clear-choice / --complex-choice / --rename-model / --delete-model), then re-run.

-u all refuses: “a module on disk is OLDER than the schema this database already carries”. You deployed code whose ledger stops at a lower id than the database has already applied — a downgrade. Migrations are forward-only; there is no down-migration. Restore the newer module version, or restore the database from a backup taken before the upgrade.

Existing rows are NULL after adding a required=True field, or the upgrade fails enforcing NOT NULL. The default couldn’t produce a value while the migration ran — usually because it depends on records the module only creates in its post_install hook. Write the default so it derives from the record’s own data (e.g. self.company’s warehouse) rather than the current user’s context; the framework then backfills after the hook runs (see Required fields whose default depends on install-hook data).

-u all fails with current transaction is aborted, commands ignored until end of transaction block. That is never the real error — Postgres reports it for every statement after the one that actually failed, so the traceback points at whichever statement happened to run next. The message includes the originating statement and its own error underneath (“The transaction was aborted earlier by: …”); diagnose from that line, not from the traceback’s frames.

My model change doesn’t take effect at all. Module Python is imported once, not re-executed per request — a live edit isn’t picked up until the server/worker process restarts. Restart, then re-run the upgrade.

Can I just hand-edit schema_changes.yaml or schema_baseline.json? No. Never reorder, reuse, or delete ids in the ledger (ids are the replay clock) and never hand-edit the baseline. Add changes only through resolve, and regenerate the baseline only through resolve --snapshot. Before an entry has shipped, back it out by reverting the files (net-zero), not by editing ids in place.

A migration I recorded turned out to be unnecessary — can I delete the entry? Not once any database has replayed it. Deleting it makes that database refuse to upgrade at all (its mark is higher than anything the code ships, which reads as a downgrade), and check --only schema fails the build for exactly that reason. Leave the entry where it is — replay skips what a database has already applied, and it costs a fresh install one no-op. If nothing has replayed it yet, revert the ledger and the baseline together (see Backing out a change before it ships), which drops the id from both sides.

CommandPurposeDB needed
fullfinity-server -u allUpgrade: additive sync + ledger replay, atomicyes
fullfinity-server --all-databases -u allSame, against every owned database (skips test/scratch)auto-discovered
fullfinity-server check --only schemaGate: fail on a breaking field-surface changeno
fullfinity-server check --only version-bump --tag vX.Y.ZRelease gate: the tag’s SemVer bump must match the change class since the last releaseno
fullfinity-server resolveRecord a rename/delete/complex changeno
fullfinity-server resolve --snapshotRegenerate the committed baselineno