---
title: "Field policy"
description: "legacy and all policies, the shared-field denylist, the dry run and the back-fill."
requested_language: fr
language: en
translation_notice: "This page isn't translated yet"
url: https://docs.systhema.app/fr/payload/localization/field-policy
version: unreleased (main)
docs_index: https://docs.systhema.app/fr/llms.txt
---
> This page isn't translated yet. Showing English.


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/fr/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/fr/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`.
