Database migrations
The rename lane and the shape lane, the upgrade pre-flight and safe ordering.
On this page
For a PostgreSQL production database that has only used dev push, use systhema migrate --schema to review and apply missing schema objects without prompts. It is additive only, uses the resolved project config, and needs no Payload migration history. The worked example covers a canary upgrade needing general_settings plus 55 columns, including the ordering of nav renames and SEO data copies.
Systhema evolves the Payload schema between releases. Two very different kinds of change can reach your database, and they are handled in two different lanes. Knowing which lane a change is in tells you who owns it, when it runs, and what you have to do.
| Lane | What changes | Who owns it | When it runs |
|---|---|---|---|
| A — object renames | The NAME of a table / index / constraint / enum / sequence. Data is untouched. | Systhema, with database consent | During systhema upgrade --allow-database |
| B — data shape changes | Columns, tables, relations — the shape the data is stored in. | Your project, via a Payload migration you generate | When you run payload migrate, on your schedule |
Lane A — object renamesLink to this section
Payload derives physical table names from each collection's / global's dbName. When a Systhema release changes a dbName, the new code looks for a table that doesn't exist yet while the old one is still sitting there. On the next schema push, Drizzle can't tell a rename from a create + drop, so it asks interactively:
Is header_nav table created or renamed from another table?
❯ + header_nav create table
~ header_navigation › header_nav rename tableThat prompt hangs a non-interactive systhema upgrade or CI run, and answering it wrong orphans (or drops) the data.
So Systhema reconciles the names for you, before any push, with a post-upgrade hook — a small script that talks to the database directly (never through getPayload(), which would trigger the very push we're avoiding). Every such hook is:
- transactional — a failure rolls back, nothing lands half-done;
- idempotent — a second run performs zero operations;
- catalog-driven — it renames the objects that actually exist on each table (constraints, indexes, enums, sequences on Postgres; indexes on SQLite), rather than guessing generated names;
- scoped — it only touches the exact objects the release renamed.
systhema upgrade --allow-database runs these hooks after pnpm install. Without that flag, including under --yes, the plan lists them as excluded. Run any required migrations before starting or deploying the updated app. The JSON plan identifies each database-writing hook and its --skip <id> flag. To run one on its own — after restoring a database snapshot, on a second environment, or because you skipped it — use:
systhema migrate --list # what's available
systhema migrate migrate-header-nav-dbnames # run oneBoth Postgres and SQLite are affectedLink to this section
dbName becomes the physical table name verbatim on every adapter — createTableName lives in the adapter-shared @payloadcms/drizzle, and @payloadcms/db-sqlite uses the same _v versions suffix as Postgres. A SQLite project therefore carries exactly the same stale tables and hits the same interactive prompt. (What differs is only the surrounding objects: SQLite has no named constraints, enum types or sequences, and it rewrites child-table foreign-key references itself when a table is renamed.) MongoDB has no schema push and is a genuine no-op.
If you upgraded past the release windowLink to this section
A post-upgrade hook only runs while your project version passes through the release that introduced it. If you jumped several versions in one go, or declined the hook, the old tables are still live and the next pnpm dev will hit the prompt.
systhema doctor catches exactly that, version-independently — it connects read-only to your database and compares the live table names against what the installed Systhema expects:
systhema doctor
# ✖ Header navigation tables: 2 legacy table name(s) still in the database
# header_navigation → header_nav, _header_v_version_navigation → _header_nav_v
# → systhema migrate migrate-header-nav-dbnamesThe check is report-only — renaming tables belongs in the migration script, not in doctor --fix — and it is a slow check (it opens a database connection), so it never runs in the quick nudge other commands print. It reports a name only when the target table is genuinely missing, so a project that legitimately owns a table sharing a legacy name (nav is generic enough) stays quiet once the rename has landed. It is also silent when DATABASE_URI is unset or the database is unreachable, and it never brings a missing SQLite file into existence.
Lane B — data shape changes (project-owned)Link to this section
A change to the shape of stored data — a new column, a table split, a relation that becomes localized — cannot be reconciled by renaming things. Payload already has the right tool for it: migrations (opens in new tab). Systhema does not generate migrations for your project, because the correct migration depends on your collections, your locales and your data.
The division of labour is:
- Systhema ships the data helper — a bin script that moves/reshapes the CONTENT (e.g. copying an old global's values into their new home).
- Your project ships the migration — the schema DDL, generated by Payload from your own config, committed to your repo, and applied on your own schedule (
payload migratein your deploy step).
The upgrade pre-flightLink to this section
When an upgrade carries a shape-changing step and your project has no Payload migration directory, systhema upgrade stops before touching anything:
Blocked — Payload migrations required
<step-id> <what it does>
This step changes the shape of stored data, so Payload must own the schema
transition. No migration directory was found, which means the project still
pushes its schema in dev — and Drizzle would resolve the change interactively.
Do this first:
1. pnpm exec payload migrate:create
2. commit the generated migration, then re-run this upgradeA project with no migrations is still on dev push, which is exactly the interactive-resolution mode that loses data on a shape change. Creating the migration directory (and its baseline) first is what makes the transition reviewable and repeatable.
If you know what you're doing and want to handle the transition another way, systhema upgrade --skip <step-id> proceeds without it.
The recommended order for a shape changeLink to this section
- Run required object renames before creating replacement objects. Use the dedicated localized-status helper for that transition.
- Generate and review the schema migration against the project's existing snapshots. For additive changes on a push-only PostgreSQL database, use
systhema migrate --schema --dry-runinstead. Runningpayload migrate:createwithout a baseline generates the whole schema, not a live-database diff. - Apply destination schema additions before running helpers that copy into them. In particular, create
general_settingsbeforemigrate-seo-dataormigrate-emails-data. On a project with content localization that also meansgeneral_settings_locales:migrate-seo-dataandmigrate-emails-dataresolve each field's destination from the loaded config, and a field the config localizes is written to the locales table — once per configured locale, with_parent_idpointing at the base row — not to the base table. Both thelegacypolicy (which localizeswebsiteName,titleTemplate,descriptionandimage) andpolicy: 'all'(which localizes whole tabs, the Emails tab included) put it there; either hook exits 1 when the locales table has not been pushed yet, rather than adding dead columns to the base table. - Copy and verify the data while the legacy source still exists. Deploy code at the point required by that transition.
- Drop orphaned objects only in a separately reviewed cleanup migration.
The automated Lane B changeLink to this section
Per-locale publishing is a shape change, and it is the one Lane B change Systhema carries end to end. localization.localizeStatus is on by default with locales, so a multi-locale project meets it on upgrade: _status moves out of its shared column on pages, posts and components and into their _locales siblings.
The division of labour above still holds, but nothing here is hand-written. systhemaLocalizeStatus performs the whole transition itself. It introspects the column it is moving and rebuilds it, index included, so the result is the schema a fresh push produces. systhema upgrade applies it in place (systhema migrate migrate-localize-status runs it standalone), and on a project that has a migration directory it also generates the Payload migration file, snapshot included, that production applies in its deploy step. The startup drift check and systhema doctor's localize-status-schema report a database that has not had it yet.
There is no safe halfway state: the helper moves the columns, so neither the old code nor the new code works on the other side of it. Code and migration go out in one deploy.
Renaming a stored design keyLink to this section
Both lanes above describe changes a Systhema release makes. There is a third kind, and it is one you trigger at any time: renaming a design key that Payload stores as a value.
Two key spaces are stored values, not just token names:
colorSystemmode keys —default,dark, and any mode you add.layout.bgslot keys — the layout-background options.
They are the option sets of real fields, so the chosen key is written into rows:
| Where | Field |
|---|---|
Pages / Posts (and their _v version tables) | articleTheme |
| Archive pages | listing.theme, listing.layoutBg |
| General Settings | the cookie-consent theme |
| Every Lexical block (Section, Card, Feature, Gallery, Columns, Posts) | theme / layoutBg, inside the rich-text JSON |
What happens if you rename one anywayLink to this section
- Postgres — those fields are real enum types (one per table, so the
_vtwin has its own). Renaming a mode reads to Drizzle as "drop a value, add a value", and its migration ends withALTER COLUMN … SET DATA TYPE <enum> USING …. That cast fails on any row still holding the old key, so the next schema push errors out. - SQLite — the same fields are plain
TEXT, so the push succeeds and nothing complains. The stored values simply stop matching any mode, and those pages quietly render with the fallback theme. This is the quieter failure, not the safer one.
The safe orderLink to this section
- Decide the rename, but do not apply it to the project yet.
- Update the stored values first — a Payload migration (
payload migrate:create) that rewrites the old key to the new one in every column above and in the rich-text JSON, applied withpayload migrate. - Only then apply the design change (
systhema design apply, an overlay zip, or a hand edit ofcustomTokens) and let the schema push run.
On Postgres, step 2 is cheap: ALTER TYPE "<enum>" RENAME VALUE '<old>' TO '<new>' is metadata-only — no table rewrite, no cast — and it leaves the database already agreeing with the new schema. Systhema ships no helper for this yet; write the migration for your own project's schema.
systhema design color mode rename knows about this: inside a Payload project it asks for confirmation before the rename and points back here afterwards (pass --yes to accept non-interactively). Adding, duplicating and reordering modes are all safe — only a rename (or a removal) invalidates values that are already stored.
Don't combine a drop with a schema-wide changeLink to this section
The most dangerous upgrade is not any single step — it is two steps in the same push.
Concretely: after migrating the legacy seo global into General Settings, the old seo table is left behind, orphaned. It is tempting to drop it in the same dev push in which you enable localization (or any other change that adds/renames a batch of tables). Don't.
Drizzle's rename resolver works on the SET of created and deleted tables in one diff. Dropping seo puts it in "deleted"; enabling locales puts a batch of new tables in "created". The resolver sees both and offers to "rename" seo into one of the new locale tables — and if that is answered wrong (or accepted by a scripted yes), your SEO data is gone and a locale table is silently seeded with the wrong rows.
The safe order is one change per push:
systhema migrate migrate-seo-data— copy the values intogeneral-settings.- Verify the data landed in the admin UI.
- Push #1 — drop the now-orphaned
seotable, on its own. Nothing else pending. - Push #2 — enable locales (or whatever the schema-wide change is), with no deletions pending.
The same rule generalizes: never let a deletion and an unrelated batch of creations meet in the same schema diff. If a push offers you a rename you did not intend, answer "create" and investigate — never accept a rename between two objects that have nothing to do with each other.