Docs
Systhema Design (opens in new tab)
Unreleased

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.

LaneWhat changesWho owns itWhen it runs
A — object renamesThe NAME of a table / index / constraint / enum / sequence. Data is untouched.Systhema, with database consentDuring systhema upgrade --allow-database
B — data shape changesColumns, tables, relations — the shape the data is stored in.Your project, via a Payload migration you generateWhen 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 table

That 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 one

Both 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-dbnames

The 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 migrate in 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 upgrade

A 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.

  1. Run required object renames before creating replacement objects. Use the dedicated localized-status helper for that transition.
  2. 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-run instead. Running payload migrate:create without a baseline generates the whole schema, not a live-database diff.
  3. Apply destination schema additions before running helpers that copy into them. In particular, create general_settings before migrate-seo-data or migrate-emails-data. On a project with content localization that also means general_settings_locales: migrate-seo-data and migrate-emails-data resolve 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_id pointing at the base row — not to the base table. Both the legacy policy (which localizes websiteName, titleTemplate, description and image) and policy: '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.
  4. Copy and verify the data while the legacy source still exists. Deploy code at the point required by that transition.
  5. 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:

  • colorSystem mode keys — default, dark, and any mode you add.
  • layout.bg slot keys — the layout-background options.

They are the option sets of real fields, so the chosen key is written into rows:

WhereField
Pages / Posts (and their _v version tables)articleTheme
Archive pageslisting.theme, listing.layoutBg
General Settingsthe 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 _v twin has its own). Renaming a mode reads to Drizzle as "drop a value, add a value", and its migration ends with ALTER 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

  1. Decide the rename, but do not apply it to the project yet.
  2. 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 with payload migrate.
  3. Only then apply the design change (systhema design apply, an overlay zip, or a hand edit of customTokens) 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:

  1. systhema migrate migrate-seo-data — copy the values into general-settings.
  2. Verify the data landed in the admin UI.
  3. Push #1 — drop the now-orphaned seo table, on its own. Nothing else pending.
  4. 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.