Summary
Two independent cases where generate:db-schema emits an enum for a _status column that the database does not actually have as an enum. Filed together because they are the same theme, and a fix for either affects how the other should be resolved.
- A user-declared
_status field has its options concatenated onto the injected ones, producing an enum Postgres rejects outright.
- The
localizeStatus migration helper creates the column as varchar, while the generator declares an enum for it.
With push: false the generated schema is never executed, so neither surfaces at the point the mismatch appears — it surfaces later, as a hand-written migration built from a wrong mirror.
Case 1 — a redeclared _status concatenates its options, producing an invalid enum
This one also reaches payload-types.ts, which application code compiles against.
Reproducing it
A Postgres collection with drafts enabled that redeclares _status — we did this to attach a custom list Cell:
export const Post: CollectionConfig = {
slug: 'post',
versions: { drafts: true },
fields: [
{
name: '_status',
type: 'select',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
admin: { components: { Cell: '/components/StatusCell' } },
},
],
}
payload generate:db-schema
payload generate:types
Actual
// payload-generated-schema.ts
export const enum_post_status = pgEnum('enum_post_status', [
'draft', 'published', 'draft', 'published',
])
export const enum__post_v_version_status = pgEnum('enum__post_v_version_status', [
'draft', 'published', 'draft', 'published',
])
// payload-types.ts
_status?: ('draft' | 'published' | 'draft' | 'published') | null;
Expected ['draft', 'published'] and ('draft' | 'published') | null.
Why it matters
The generated DDL cannot be executed. On postgres:17:
postgres=# CREATE TYPE dup AS ENUM('draft','published','draft','published');
ERROR: duplicate key value violates unique constraint "pg_enum_typid_label_index"
DETAIL: Key (enumtypid, enumlabel)=(16385, draft) already exists.
Separately from the SQL, the malformed union in payload-types.ts is what application code is typed against.
Cause
sanitizeCollection merges the user's fields with baseVersionFields:
https://github.com/payloadcms/payload/blob/v3.85.2/packages/payload/src/collections/config/sanitize.ts#L258-L263
sanitized.fields = mergeBaseFields(
sanitized.fields,
baseVersionFields({
localized: sanitized.versions.drafts.localizeStatus ?? false,
}),
)
mergeBaseFields merges each matching field with deepMergeWithReactComponents:
https://github.com/payloadcms/payload/blob/v3.85.2/packages/payload/src/fields/mergeBaseFields.ts#L26
which does not set arrayMerge, so deepmerge's default — concatenation — applies:
https://github.com/payloadcms/payload/blob/v3.85.2/packages/payload/src/utilities/deepMerge.ts#L47-L51
export function deepMergeWithReactComponents<T extends object>(obj1: object, obj2: object): T {
return deepMerge<T>(obj1, obj2, {
isMergeableObject: isPlainObject,
})
}
baseVersionFields already carries options: statuses:
https://github.com/payloadcms/payload/blob/v3.85.2/packages/payload/src/versions/baseFields.ts#L29
so a user-supplied options array is appended rather than replacing it.
In practice only _status is affected — it is the only base field carrying an options array that users routinely redeclare. Collections that do not redeclare it are unaffected.
Workaround
options: [] on the redeclared field. The key cannot be omitted; options is required by the SelectField type.
Possible fix
A replace-not-concat strategy for options when merging base fields. Note that deepMergeWithSourceArrays already exists in the same module and does exactly this. Concatenation cannot produce a valid result for any enum-backed field, so overriding seems like the only sensible reading of a user redeclaring one — but deepMergeWithReactComponents has other callers, so the safer change is probably at the mergeBaseFields call site rather than in the shared helper. Happy to send a PR if you have a preference on which.
Case 2 — localizeStatus creates varchar, the generator declares an enum
Reproducing it
With experimental.localizeStatus: true on Postgres, run Payload's own helper:
import { localizeStatus } from 'payload/migrations'
await localizeStatus.up({ collectionSlug: 'post', db: payload.db, payload, req, sql })
then payload generate:db-schema.
Actual
The helper creates varchar:
https://github.com/payloadcms/payload/blob/v3.85.2/packages/payload/src/versions/migrations/localizeStatus/sql/up.ts#L264
ADD COLUMN _status VARCHAR DEFAULT 'draft'
https://github.com/payloadcms/payload/blob/v3.85.2/packages/payload/src/versions/migrations/localizeStatus/sql/up.ts#L157
ALTER TABLE ${sql.identifier(localesTable)} ADD COLUMN version__status VARCHAR
The generator declares an enum for those same columns:
post_locales._status -> enum_post_status
_post_v_locales.version__status -> enum__post_v_version_status
information_schema reports character varying for all of them. The enum types are still created, but end up orphaned — nothing references them.
In our project this affects six columns: _status on work_locales / collection_locales / author_locales, and version__status on the three corresponding _v_locales tables.
Why it matters
Anyone hand-writing DDL from the generated mirror writes an enum column where the database has a varchar, and it recurs for every collection anyone localizes status on.
Expected
The helper and the generator should agree — either the helper creates and uses the enum type, or the generator emits varchar for localized _status. We have accepted the database side and documented the divergence, so this is not blocking us; which side should change looks like a genuine design decision rather than an obvious bug, which is why it is filed here for discussion rather than with a patch.
Version
Case 1 was found on 3.85.2, and generate:db-schema output is byte-identical between 3.85.2 and 3.87.1 (verified by direct diff during an upgrade); the cited sanitize.ts / mergeBaseFields.ts / deepMerge.ts code is also unchanged on main at the time of writing, though sanitize.ts has shifted to L286.
Case 2 was observed on 3.85.2 only. localizeStatus is a migration helper rather than generator output, so it was not part of that comparison and I have not checked it across versions.
Common thread
In both cases the generated schema is the only wrong artifact, and push: false means nothing ever executes it to surface the disagreement. A CI job that regenerates the file catches staleness but by construction cannot catch either of these — the generator reproduces its own bugs deterministically.
A third instance of the same shape, the locale enum being declared as enum__locales when the adapter creates _locales, is filed separately as #17736, with a patch in #17737, since that one needs no design decision.
Summary
Two independent cases where
generate:db-schemaemits an enum for a_statuscolumn that the database does not actually have as an enum. Filed together because they are the same theme, and a fix for either affects how the other should be resolved._statusfield has itsoptionsconcatenated onto the injected ones, producing an enum Postgres rejects outright.localizeStatusmigration helper creates the column asvarchar, while the generator declares an enum for it.With
push: falsethe generated schema is never executed, so neither surfaces at the point the mismatch appears — it surfaces later, as a hand-written migration built from a wrong mirror.Case 1 — a redeclared
_statusconcatenates its options, producing an invalid enumThis one also reaches
payload-types.ts, which application code compiles against.Reproducing it
A Postgres collection with drafts enabled that redeclares
_status— we did this to attach a custom listCell:Actual
Expected
['draft', 'published']and('draft' | 'published') | null.Why it matters
The generated DDL cannot be executed. On
postgres:17:Separately from the SQL, the malformed union in
payload-types.tsis what application code is typed against.Cause
sanitizeCollectionmerges the user's fields withbaseVersionFields:https://github.com/payloadcms/payload/blob/v3.85.2/packages/payload/src/collections/config/sanitize.ts#L258-L263
mergeBaseFieldsmerges each matching field withdeepMergeWithReactComponents:https://github.com/payloadcms/payload/blob/v3.85.2/packages/payload/src/fields/mergeBaseFields.ts#L26
which does not set
arrayMerge, so deepmerge's default — concatenation — applies:https://github.com/payloadcms/payload/blob/v3.85.2/packages/payload/src/utilities/deepMerge.ts#L47-L51
baseVersionFieldsalready carriesoptions: statuses:https://github.com/payloadcms/payload/blob/v3.85.2/packages/payload/src/versions/baseFields.ts#L29
so a user-supplied
optionsarray is appended rather than replacing it.In practice only
_statusis affected — it is the only base field carrying anoptionsarray that users routinely redeclare. Collections that do not redeclare it are unaffected.Workaround
options: []on the redeclared field. The key cannot be omitted;optionsis required by theSelectFieldtype.Possible fix
A replace-not-concat strategy for
optionswhen merging base fields. Note thatdeepMergeWithSourceArraysalready exists in the same module and does exactly this. Concatenation cannot produce a valid result for any enum-backed field, so overriding seems like the only sensible reading of a user redeclaring one — butdeepMergeWithReactComponentshas other callers, so the safer change is probably at themergeBaseFieldscall site rather than in the shared helper. Happy to send a PR if you have a preference on which.Case 2 —
localizeStatuscreatesvarchar, the generator declares an enumReproducing it
With
experimental.localizeStatus: trueon Postgres, run Payload's own helper:then
payload generate:db-schema.Actual
The helper creates
varchar:https://github.com/payloadcms/payload/blob/v3.85.2/packages/payload/src/versions/migrations/localizeStatus/sql/up.ts#L264
https://github.com/payloadcms/payload/blob/v3.85.2/packages/payload/src/versions/migrations/localizeStatus/sql/up.ts#L157
The generator declares an enum for those same columns:
information_schemareportscharacter varyingfor all of them. The enum types are still created, but end up orphaned — nothing references them.In our project this affects six columns:
_statusonwork_locales/collection_locales/author_locales, andversion__statuson the three corresponding_v_localestables.Why it matters
Anyone hand-writing DDL from the generated mirror writes an enum column where the database has a varchar, and it recurs for every collection anyone localizes status on.
Expected
The helper and the generator should agree — either the helper creates and uses the enum type, or the generator emits
varcharfor localized_status. We have accepted the database side and documented the divergence, so this is not blocking us; which side should change looks like a genuine design decision rather than an obvious bug, which is why it is filed here for discussion rather than with a patch.Version
Case 1 was found on 3.85.2, and
generate:db-schemaoutput is byte-identical between 3.85.2 and 3.87.1 (verified by direct diff during an upgrade); the citedsanitize.ts/mergeBaseFields.ts/deepMerge.tscode is also unchanged onmainat the time of writing, thoughsanitize.tshas shifted to L286.Case 2 was observed on 3.85.2 only.
localizeStatusis a migration helper rather than generator output, so it was not part of that comparison and I have not checked it across versions.Common thread
In both cases the generated schema is the only wrong artifact, and
push: falsemeans nothing ever executes it to surface the disagreement. A CI job that regenerates the file catches staleness but by construction cannot catch either of these — the generator reproduces its own bugs deterministically.A third instance of the same shape, the locale enum being declared as
enum__localeswhen the adapter creates_locales, is filed separately as #17736, with a patch in #17737, since that one needs no design decision.