---
title: "Database migrations"
description: "The rename lane and the shape lane, the upgrade pre-flight and safe ordering."
url: https://docs.systhema.app/payload/database/migrations
version: unreleased (main)
docs_index: https://docs.systhema.app/llms.txt
---

For a PostgreSQL production database that has only used dev push, use [`systhema migrate --schema`](https://docs.systhema.app/payload/database/schema-migration.md) 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 renames

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**:

```text
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:

```bash
systhema migrate --list                       # what's available
systhema migrate migrate-header-nav-dbnames   # run one
```

### Both Postgres and SQLite are affected

`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 window

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:

```bash
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)

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](https://payloadcms.com/docs/database/migrations). 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-flight

When an upgrade carries a shape-changing step and your project has **no Payload migration directory**, `systhema upgrade` stops before touching anything:

```text
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.

### The recommended order for a shape change

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`](https://docs.systhema.app/payload/database/schema-migration.md) 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 change

[Per-locale publishing](https://docs.systhema.app/payload/localization/per-locale-publishing.md) 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 key

> [!WARNING]
> Renaming a `colorSystem` mode or a `layout.bg` slot invalidates values already stored in the database. Update the stored values first.

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:

| 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 anyway

- **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 order

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 change

> [!WARNING]
> Dropping a table and creating an unrelated batch of tables in the same schema push can make Drizzle offer to "rename" one into the other and lose data.

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.
