---
title: "Field policy"
description: "legacy and all policies, the shared-field denylist, the dry run and the back-fill."
url: https://docs.systhema.app/next/payload/localization/field-policy
version: unreleased (main)
docs_index: https://docs.systhema.app/next/llms.txt
---

When locales are enabled, something has to decide which fields are stored per locale and which are shared across all of them. That decision is the **field policy**, and Systhema ships two.

| `localization.policy` | What is localized                                                                               | Default            |
| --------------------- | ----------------------------------------------------------------------------------------------- | ------------------ |
| `'legacy'`            | Fields whose NAME appears on a fixed allowlist (`title`, `slug`, `content`, `label`, `url`, …). | **Yes**, until 2.0 |
| `'all'`               | Every field of every Systhema-owned schema, except the documented exceptions below.             | No                 |

A project with no locales is unaffected by either. Nothing on this page applies to a single-language site.

## Why `'all'` exists

The legacy policy matched bare field names. That conflated a field's identity with the intent behind it, and produced results nobody chose:

- **`forms.emails` stored the message per locale and the recipient shared.** Within one notification rule, what you say was per-locale and who hears it was not. "Arabic enquiries to one inbox, everything else to another" was inexpressible, and the admin gave the editor no hint of it. A null or wrong recipient is silent — the form still stores the submission and the visitor still sees the thank-you page.
- **`pages.meta.notListed` was shared**, so `noindex` was all-or-nothing across every locale. Indexing English while holding another market back is a normal requirement.
- **Select option labels could not be expressed at all**, so they needed bespoke code to inject a localized `label` beside the shared `value`.
- **A field called `url` was localized wherever it appeared**, in any block, whether or not that made sense there; a field called `emailTo` never was. Nobody decided that — it is the residue of which names happened to reach a `Set`.

Under `'all'` the default is "localized", every exception is a **path** with a written reason, and the exceptions live in one auditable list (`SYSTHEMA_SHARED_FIELDS` in `packages/payload/src/config/localizationPolicy.ts`).

## Enabling it

```ts
export default withSysthema(config, {
  locales: systhemaConfig.locales,
  localization: {
    policy: 'all',
    // Optional per-project exceptions, appended to the built-in lists.
    shared: ['pages.default.hero.aspectRatio'],
    sharedRows: ['pages.default.gallery.items'],
    localized: ['pages.myTemplate.strapline'],
  },
})
```

The same options can live in `systhema.config.ts`, next to the locale contract, so they are declared once:

```ts
packages: {
  payload: {
    enabled: true,
    localization: { policy: 'all' },
  },
},
```

The plugin option wins on `policy`; the `shared` / `sharedRows` / `localized` lists from both sources are **appended**, never replaced.

> [!WARNING]
> **This is a schema change.** Do not set `policy: 'all'` on a site that already holds content without reading [Migrating an existing site](#migrating-an-existing-site) first.

## Ownership is still the outer gate

The inversion only reaches schemas Systhema authored. It never infers localization for:

- consumer collections and globals (`customCollections` / `customGlobals`);
- consumer-authored page/post **template** fields.

Built-in templates mark their own fields, so everything Systhema ships is covered. A consumer template field is opted in by path:

```ts
localization: { policy: 'all', localized: ['pages.caseStudy.strapline'] }
```

An explicit `localized: false` on a field always wins, over the denylist and over `localized` alike.

## Arrays and blocks: which half is per-locale

An `array` or `blocks` field has two halves, and localizing it means picking one. They are different features with different migrations, so the choice is explicit per container.

**Localized rows — the default.** The container carries `localized: true`; each locale gets its own rows, in its own order, of its own block types. Its leaves are then NOT localized, because Payload forbids a localized field inside a localized one. This is what translating a page body or a navigation actually needs.

**Shared rows — `sharedRows`.** The container stays shared and its leaves are localized: one row set, per-locale values. Correct wherever the rows are a schema, a key set, or a list of facts rather than content.

Neither is free:

- A **localized-rows** container inherits its fallback locale's rows _including their ids_ the first time an editor opens an untranslated locale. Saving them raises `ValidationError: id — Value must be unique`, and the editor has to re-create the rows for that locale. This predates the inverted policy — `header.navigation` and `footer.main` have always behaved this way — but `'all'` makes more containers behave like it. The back-fill migration below avoids it for existing content by copying rows with fresh ids.
- A **shared-rows** container cannot express "this market lists four services, that one lists six".

If the schema already declares `localized: true` on a leaf inside a container, the policy reads that as the author choosing the leaves-localized model and keeps the rows shared, rather than producing an invalid nested-localized schema out of two decisions that each looked fine on their own.

## What stays shared, and why

The bar for an entry is "localizing this is **wrong**", not "localizing this is inconvenient". A field that merely costs an editor a second edit per locale is not an entry — that is the trade `'all'` deliberately makes. A project that disagrees adds its own path to `shared`.

### Submission keys

| Path                                              | Reason                                                                                                                                      |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `forms.fields.*.name`                             | The submission key. Localizing it stores one answer under a different column per domain and scatters a single question across a CSV export. |
| `forms.fields.*.options.value`                    | The stored answer. `label` beside it carries the translation.                                                                               |
| `forms.fields.payment.priceConditions.fieldToUse` | Names another form field by its shared `name`; a per-locale value would dangle.                                                             |
| `forms.fields.payment.paymentProcessor`           | A payment-integration identifier, not text a visitor reads.                                                                                 |
| `forms.fields.upload.uploadCollection`            | A Payload collection slug — a schema reference.                                                                                             |
| `forms.fields.upload.mimeTypes.mimeType`          | A protocol identifier from the IANA registry, not language.                                                                                 |

### Upload identity

| Path                 | Reason                                                            |
| -------------------- | ----------------------------------------------------------------- |
| `uploads.uploadedBy` | Provenance of the binary. One file, uploaded once, by one person. |
| `uploads.filename`   | One binary, one set of facts about it.                            |
| `uploads.mimeType`   | One binary, one set of facts about it.                            |
| `uploads.filesize`   | One binary, one set of facts about it.                            |
| `uploads.width`      | One binary, one set of facts about it.                            |
| `uploads.height`     | One binary, one set of facts about it.                            |

> [!NOTE]
> Localizing an upload document is not how you ship a different key visual per market — the file itself has to stay shared. Under `'all'`, the **upload relationship fields on pages and posts** are localized, so each locale points at its own image. That is the model; it no longer has to be worked around per project.

### Access control

| Path                 | Reason                                                                                                                                                  |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `users.roles`        | Authorization. A per-locale role means an editor who is an admin on one domain and nothing on another, decided by whichever locale the request carried. |
| `users.capabilities` | Authorization, for the same reason.                                                                                                                     |
| `users.avatar`       | An account attribute of a person, not site content addressed at a market.                                                                               |

### Structural keys Systhema itself reads

| Path                      | Reason                                                                                                                                                        |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pages.parent`            | The nested-docs edge. A per-locale parent would give a page different ancestors — and different breadcrumbs — depending on the request.                       |
| `pages.pageTemplate`      | Selects WHICH schema the rest of the document is stored under. A per-locale template makes a document two shapes at once.                                     |
| `posts.postTemplate`      | The same.                                                                                                                                                     |
| `posts.postType`          | A classification key the archive filters and queries on. Localizing it drops a post out of its own listing in some locales.                                   |
| `**.slugLock`             | A UI latch meaning "re-derive the slug when the title changes". Localizing it drops the shared column from `pages` and `_pages_v` — destructive, for nothing. |
| `**.keyLock`              | The `slugLock` of a form field, latching the shared `name`.                                                                                                   |
| `**._tier` / `**._inCard` | Written by the tier detector to record where a block sits in the editor nesting. Structure, not language.                                                     |

### Cookie-consent inventory

The consent table is a legal declaration of what the site sets in a visitor's browser. Its prose is translated; its facts are not. `general-settings.cookieConsent.cookies` keeps shared rows (a locale must not be able to declare a different inventory from the code that runs on every domain), and these stay shared inside them — `description` and `duration` are localized.

| Path                                              | Reason                                              |
| ------------------------------------------------- | --------------------------------------------------- |
| `general-settings.cookieConsent.cookies.name`     | The cookie name a browser actually stores.          |
| `general-settings.cookieConsent.cookies.domain`   | The domain the cookie is set on.                    |
| `general-settings.cookieConsent.cookies.category` | The consent category gating the cookie.             |
| `general-settings.cookieConsent.cookies.party`    | First- or third-party is a fact, not a description. |

### Infrastructure and plumbing

| Path                          | Reason                                                                                                                                                                                                                                              |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cloudflare.autoPurge`        | A site-wide CDN switch, and the only persisted field on the Cloudflare global. Its self-saving POST and its runtime lookup both carry no locale, so a per-locale value would leave them reading the default while other locales held stale toggles. |
| `general-settings.__seededAt` | Records whether the cookie-consent defaults have been seeded for this INSTALL. A per-locale marker would re-seed the same global once per locale.                                                                                                   |

### Records of events

`form-submissions.**` and `systhema-ai-usage.**` are excluded wholesale. A submission is one event that happened once, in one language; a usage ledger row records one generation. There is no translation of a fact.

### The form submit button

`forms.submitButtonTitle` is the button a visitor clicks. It is localized under `'all'` and has **never** been localized under `'legacy'`: the legacy allowlist names `submitButtonLabel`, which `applySysthemaFormDefaults` deletes before the policy runs, while `buildSubmitButtonFields` splices in a differently-named field. The dead entry has been removed rather than repointed, because adding `submitButtonTitle` to the legacy set would move a column on the next deploy of a project that changed nothing.

A project staying on `'legacy'` can fix just this one field:

```ts
localization: { policy: 'legacy', localized: ['forms.submitButtonTitle'] }
```

`localized` is additive and works under either policy, so it never surprises a project that declared nothing. `shared` and `sharedRows` apply to `'all'` only — under `'legacy'`, `localized: false` on the field is already the way to keep something shared.

### Deliberately NOT on the list

`posts.publishedAt` and `posts.author` **are** localized under `'all'`. A translation can legitimately be published later than its source and credited to the translator. If that is wrong for your project, add them to `shared`.

## The dry run

Before changing anything, print exactly what would move:

```bash
pnpm payload systhema-localization-report          # human-readable
pnpm payload systhema-localization-report --json   # machine-readable
pnpm payload systhema-localization-report --no-counts
```

It reads the decisions out of the config your project actually loaded, rather than re-deriving the policy, so it can never describe a schema the site does not run. It writes nothing.

> [!IMPORTANT]
> **A field is not a column, and the report counts fields.** A group is several columns under one path, a `hasMany` relation is rows in a `_rels` table rather than a column, and a localized array adds a locale discriminator to its own row table _and_ its `_v_version_*` twin — none of which has a field of its own. The report names the structural changes it can derive and the fields responsible, but it deliberately does not guess table names, because a wrong table name in a migration plan is worse than no plan. **Do not scope a migration from this list.** Derive it as described in [Deriving the plan](#deriving-the-plan) and use the report to know what to expect to find.

Output from a real five-locale project (`ar`, `en`, `sk`, `cs`, `hu`), running `legacy`, with the Posts and Redirects modules and cookie consent on:

```text
Systhema content localization — dry run
  active policy : legacy
  locales       : ar, en, sk, cs, hu

100 field(s) would change from SHARED to LOCALIZED:

  footer
    shared → localized  footer.social  (array)

  general-settings  1 document(s) × 5 locale(s) = 5 rows to back-fill
    shared → localized  general-settings.cookieConsent.theme  (radio)
    shared → localized  general-settings.pages.homepage  (relationship)
    shared → localized  general-settings.posts.showAuthor  (checkbox)
    …

  pages  42 document(s) × 5 locale(s) = 210 rows to back-fill
    shared → localized  pages.default.hero.heroType  (radio)
    shared → localized  pages.listing.limit  (number)
    shared → localized  pages.meta.notListed  (checkbox)
    …

  posts  17 document(s) × 5 locale(s) = 85 rows to back-fill
    shared → localized  posts.author  (relationship)
    shared → localized  posts.publishedAt  (date)
    shared → localized  posts.meta.notListed  (checkbox)
    …

  redirects  0 document(s) × 5 locale(s) = 0 rows to back-fill
    shared → localized  redirects.to.reference  (relationship)
    shared → localized  redirects.type  (select)
    …

19 field(s) stay SHARED:

    pages.parent  (relationship)
        The nested-docs edge. The document tree is one tree; a per-locale parent
        would give a page different ancestors — and different breadcrumbs —
        depending on the request.
    …
```

## Migrating an existing site

There are four cases, and only one is dangerous.

| Case                                                    | What happens                                                                                                                              |
| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| New single-language project                             | The policy never runs. Nothing to do.                                                                                                     |
| New multi-locale project                                | Set `policy: 'all'` before creating content. No data to move.                                                                             |
| Existing single-language project turning locales on     | Already a migration moment — see [Localizing an existing Payload site](https://docs.systhema.app/next/payload/localization/migrating-existing-site.md). Choose `'all'` while you are there. |
| **Existing multi-locale project upgrading the package** | **The dangerous one.** See below.                                                                                                         |

An existing multi-locale project already has `localesEnabled === true`. If the policy widened on a package upgrade, columns would move to their `_locales` siblings on a routine `pnpm update`, with the consumer switching nothing on. So it does not: **`policy` defaults to `'legacy'`**, and the only thing an upgrade does is print a one-time notice naming the choice. `systhema doctor` reports the same thing as a `localization-policy` warning. Declaring `policy: 'legacy'` explicitly silences both — it is a decision, and the check treats it as one.

Both carry the decision inline — what moves, that a back-fill migration comes first, and `pnpm payload systhema-localization-report` to see the actual list — and then point here for the procedure. This page ships inside the installed package at `node_modules/@systhemaui/payload/docs/localization-policy.md`, so the path a warning prints resolves in the consumer's own project rather than in the Systhema repo.

> [!CAUTION]
> **If you deploy `policy: 'all'` without the schema migration, the site goes down.** A production Payload deployment runs with Drizzle push disabled, so nothing creates the new `_locales` columns; the new code queries columns that do not exist and every read errors until the DDL is applied by hand. That is a deploy-time outage, not a content bug, and it will **not** reproduce in a dev environment, where push is on.

### Deriving the plan

Do not write the operation list by reading the policy. Diff two databases:

1. Point the app at an **empty** database with the final configuration (`policy: 'all'`, every locale) and let it create the schema. That is the reference.
2. Diff the live database against it for **every** generated object, not just columns: tables, columns, `_locale` / `locale` discriminators, indexes, constraints, foreign keys, sequences and enums. `information_schema` on PostgreSQL and `PRAGMA` on SQLite are the adapter-specific inputs to that comparison, not the comparison itself — a column-only diff misses the constraints Payload expects to exist.
3. Every difference becomes an operation or a line of DDL.

A plan derived this way cannot drift from the policy, and it catches the structural columns no field list mentions. Keep the reviewed plan with the migration; it is the audit trail for a retry or a restore.

Keep **add-and-copy strictly separate from the drop**. The additive DDL plus the back-fill can ship in one deploy and the destructive cleanup in a later one, so both schema shapes coexist while the site is running and no request ever sees a half-migrated table. A code revert is a safe rollback only until localized writes begin; once editors start saving per-locale content, roll forward with a fix or restore a verified backup instead.

### The back-fill

Moving a shared column into its `_locales` sibling does not carry the data. If the existing value is not copied into every configured locale, the content silently becomes null — and null is silent by construction here: a form with no recipient still accepts the submission and still thanks the visitor.

`backfillSysthemaLocalization` is `seedSysthemaLocalization` with every operation defaulting to `fillLocales: 'all'`: each configured locale is written the value the shared column already held.

```ts
import type { MigrateUpArgs } from '@payloadcms/db-postgres'
import { sql } from '@payloadcms/db-postgres'
import {
  backfillSysthemaLocalization,
  createPayloadPostgresLocalizationSqlClient,
} from '@systhemaui/payload'

import systhemaConfig from '../../systhema.config'

export async function up({ db }: MigrateUpArgs): Promise<void> {
  // 1. The Payload-generated ADDITIVE SQL: create the new `_locales` columns.
  //    The legacy columns must still exist at this point.

  const report = await backfillSysthemaLocalization(
    createPayloadPostgresLocalizationSqlClient(db, sql),
    'postgres',
    {
      operations: [
        {
          kind: 'localeTable',
          sourceTable: 'forms_emails',
          targetTable: 'forms_emails_locales',
          columns: { email_to: 'email_to', cc: 'cc', bcc: 'bcc', reply_to: 'reply_to' },
          // The target has a NOT NULL column outside this move, and not every
          // form is translated into every locale.
          missingParentRow: 'cloneDefaultLocale',
        },
        {
          kind: 'localeTable',
          sourceTable: 'pages',
          targetTable: 'pages_locales',
          columns: { meta_not_listed: 'meta_not_listed' },
        },
        // A localized-rows array: stamp the default locale on the existing rows
        // and copy them once per remaining locale, with fresh ids.
        { kind: 'rowLocale', table: 'footer_social', fillLocales: 'all', childTables: [] },
      ],
    },
    { locales: systhemaConfig.locales, acknowledgeDefaultLocale: 'en', postgresSchema: 'public' },
  )
  console.log('[systhema] back-filled per locale:', report.operations)

  // 3. Constraints, then the DESTRUCTIVE cleanup that drops the legacy columns.
}
```

Everything the [existing-site migration guide](https://docs.systhema.app/next/payload/localization/migrating-existing-site.md) says still applies: review the generated migration, split it into additive → seed → destructive, never run the unedited generated migration first, and never let a DROP share a schema diff with an unrelated batch of CREATEs.

Behaviour worth knowing:

- **It fills the moved column into locale rows that already exist**, not only into rows it creates. A translated site has a row in every locale already, with the freshly-created column null in each; an insert-only helper would leave every one of them null.
- **A null in a moved column is "not filled yet", not a conflict.** Only a value that is present _and_ disagrees aborts the migration.
- **It never overwrites a translation.** A locale row that already exists and disagrees with the legacy value aborts the migration with the parent id, rather than replacing what someone wrote.
- **It is idempotent and resumable.** An interrupted run fills only the locales it had not reached.
- **`dryRun: true`** runs every schema and conflict check, reports `perLocale` counts, and writes nothing.
- **The locale discriminator is not always called `_locale`.** A `_locales` sibling and an array/blocks row table use `_locale`; a `_rels` table uses `locale`, with no underscore. Pass `localeColumn: 'locale'` on those operations — preflight now names the alternative when the table has it.
- **`cloneDefaultLocale` mostly bites the VERSION tables, not the live ones.** On a translated site every document already has a row in every locale, so cloning a live `_locales` table creates nothing. Historical versions are the opposite: they routinely lack rows for locales added later. Measured on a real five-locale site, one migration cloned **0 rows into `pages_locales` and 135 into `_pages_v_locales`**. Nothing reads those rows — but restoring one of those versions in the admin writes the default locale's values, **slug included**, into that locale. Leave version tables out of a `cloneDefaultLocale` operation unless a NOT NULL column forces it, and know what you are accepting if it does.
- **A NOT NULL column outside the move blocks creating a locale row.** A parent with no row in a target locale needs one INSERTed, and an insert listing only the moved columns cannot satisfy an unrelated `NOT NULL` column — `forms_locales.title` is the common one, since a required field that was already localized is NOT NULL there. Preflight refuses up front and names the columns. Set `missingParentRow: 'cloneDefaultLocale'` on that operation to seed the row from the default locale first. Weigh it per table: cloning materializes the default locale's values into a locale that previously had no row, which a `fallbackLocale: false` read can see — right for `forms_locales`, worth thinking hard about for a table Systhema resolves routes against.
- **It is exercised against both adapters.** The helper's SQL is adapter-specific — a correlated `UPDATE … COALESCE(… (SELECT …))` against a schema-qualified target, and an `INSERT INTO t … SELECT … FROM t` — so it is tested against a real PostgreSQL server as well as SQLite (`migrations/backfill.postgres.test.ts`, which skips without `SYSTHEMA_TEST_POSTGRES_URL`).
- **`fillLocales: 'all'` on a `rowLocale` operation requires `childTables`.** Copying a parent row per locale orphans its children unless they are copied and re-pointed too, and no introspection can tell a child row table from an unrelated one with a `_parent_id`. Declare `[]` to assert there are none; a non-empty list is refused with instructions, not guessed at.
- `seedSysthemaLocalization` is unchanged and still defaults to the default locale only, so migrations written before this release behave exactly as they did.

## What it costs

Measured against a real Payload SQLite instance, five locales (`ar`/`en`/`sk`/`cs`/`hu`), 120 pages with hero, SEO and listing content written in every locale — `legacy` versus `all` on the same seed:

| Measure                    | `legacy`  | `all`     | Change    |
| -------------------------- | --------- | --------- | --------- |
| `_locales` tables          | 9         | 9         | —         |
| Rows across all `_locales` | 2,520     | 2,520     | **0%**    |
| `pages_locales` columns    | 19        | 36        | +17       |
| Database file              | 3,616 KiB | 3,828 KiB | **+5.9%** |

**The row count does not change, and that is the headline.** The intuition that five locales times every field means five times the rows is wrong for this transition: every Systhema schema already had at least one localized field, so its `_locales` table and its one row per document per locale existed the moment locales were switched on at all. The inversion adds **columns to rows that already exist**. What it costs is table width, not table height.

Read latency was measured over 1,000 list reads (`limit: 10`, `depth: 1`) and 1,000 single-document reads (`depth: 2`) per policy. The difference is **not measurable above process-order noise** on this dataset: across repeated runs the delta ranged from −81% to +149% and flipped sign when the two policies were run in the opposite order, while the structural numbers above were identical on every run. Take the honest reading — the extra columns did not produce a detectable read cost here — and re-measure on your own data before assuming either direction.

Reproduce it with `packages/payload/scripts/benchmark-localization-policy.ts`; the numbers above come from `BENCH_PAGES=120 BENCH_READS=1000`.
