ADR-0048: An auto-incrementing key is a property of the column, and both of Postgres's spellings are declarable
- Status: Working — built across the six packages an integer key touches.
pgtest/serial_test.goproves the sequence supplies values against a real Postgres, andawkwardSchemacarries both spellings so the round-trip fixpoint covers them - Confidence: High that this is a property rather than a type — the filter
grammar, the sort machinery and the Go type all want the plain integer, and
making it a type would have split each of them. High that both spellings are
needed rather than one, since the whole demand came from databases that
already have the older one. Medium on the ALTER-path transitions, which are
correct and tested but rarer than the CREATE path by a wide margin, and whose
setvalstep is deliberately left to a person - Decided: 2026-08-03
- Last reviewed: 2026-08-03 — written
Context
Section titled “Context”BIGSERIAL could not be declared, and neither could GENERATED … AS IDENTITY.
introspect refused both, honestly and for a good reason — a serial imported as
an ordinary column whose default happens to name a sequence produces DDL that
does not run, because Diff renders no CREATE SEQUENCE (#119,
#124). The result was that no
auto-incrementing integer key was expressible at all.
#132 put numbers on what that cost. Across eleven applications and a shared core — 80 modules, 184 tables — one table in the entire platform was not describable, and it was this construct. Everything else imported clean. Because a drift gate is per registry (ADR-0015), that one column took its module out of the gate, and in the core schema it took two.
Three things the report made clear that the construct’s name does not:
It is usually ordering, not identity. All three tables that hit it —
activity_log, audit_log, coprocess_steps — used the serial as the tiebreak
that makes ORDER BY occurred_at DESC, id DESC a total order. Without it two
rows written in the same millisecond can swap places between two pages of one
cursor walk. A monotonic counter is what the construct is for here, which is
why “just use a UUID” is not the substitution it looks like.
It cascades to indexes. coprocess_steps_session_idx covers
(session_id, seq). With seq dropped the index could not be declared either —
so one undeclarable column took out a table and the index that was the table’s
reason for existing.
The workaround failed the test the composite-key work established. A schema
change forced by the declaration language must be defensible if sqlb vanished
tomorrow. BIGSERIAL on an audit log is not a legacy accident: it is the
smallest, densest monotonically increasing key Postgres offers, on the tables
that are pure append-only and read in insertion order. Widening it to sixteen
bytes a row, on the highest-volume tables in the system, to satisfy a
declaration language is the same argument PrimaryKeyColumns and SmallInt
were added to retire (#109,
#114).
Decision
Section titled “Decision”Auto-ness is a property of the column, not a Type. FieldDesc gains
Auto, alongside Array and Size rather than inside Type:
schema.BigSerial("id").PrimaryKey() // bigserialschema.BigInt("id").Identity().PrimaryKey() // GENERATED BY DEFAULT AS IDENTITYschema.Int("attempt").IdentityAlways() // GENERATED ALWAYS AS IDENTITYNot TypeBigSerial. A bigserial column is a bigint — that is what the
catalog reports, what an ALTER COLUMN TYPE has to name, and what comes back
when you read it. A type constant would have given int64 two spellings, split
the filter grammar and the sort machinery in the same way
ADR-0033 refused for arrays, and made a column that
stops auto-incrementing a type change — a table rewrite — rather than what it
actually is, a default going away.
The evidence that this is the right cut is that scalarSQLType did not change.
bigserial is produced by a separate function that only columnDef calls,
because it is not a type: it is a macro for a column, a sequence and a default,
and letting it leak into the type function would make every diff over a serial
column propose changing bigint to bigserial forever.
Both spellings, and the older one is not deprecated here. Identity is what
Postgres recommends and is genuinely cheaper to own — one object instead of
three, no sequence to name, and an ALTER that adds and drops it exactly. But
the entire demand for this feature came from databases that already have
serials, and a DSL that could only declare the modern spelling would propose
rewriting the column on its first diff. That is the permanently-red gate the
whole import path exists to avoid.
Everything downstream of the DDL asks one question, and it is not “is this a
sequence”. FieldDesc.DatabaseSupplied() folds Default, serial and identity
together, and it is what the create body, the CLI flag help, the manifest, the
contract snapshot and the default struct tag all read. That tag is what makes
the runtime work unchanged: the engine already omits a zero value in a column
marked default, so the sequence fires without mutate.go learning what a
sequence is. Adding the construct touched no write path.
GENERATED ALWAYS is read-only, and says so at declaration. An INSERT
naming such a column is an error rather than an override, so IdentityAlways()
sets ReadOnly itself — the same move ADR-0041 makes
for a computed column, and for the same reason: saying it once here keeps the
generated create and update bodies correct without every write path knowing the
feature exists.
What introspect still refuses, it refuses by name. A sequence under a
non-integer column and a nullable auto column are both legal Postgres and have
no reading in the DSL. Each loses one column and says why, rather than reaching
Validate — which fails the whole import.
The sequence name is derived, not recorded. A declaration says that a
sequence supplies the column, not which one. A database whose sequence is called
something else round-trips correctly — nothing diffs against the name, so no
gate goes red — but a database rebuilt from Diff gets the conventional
<table>_<column>_seq. This is the one lossy edge, and it is the same trade
CheckName was added to avoid for enums (#53).
The difference is that a check’s name is compared and a sequence’s is not, so
pinning it would add a field nothing reads.
Setting the sequence past existing rows is a hazard, not a generated
statement. When a column becomes a serial or an identity on a populated
table, the counter starts at 1. The change names the setval or RESTART WITH
to run first and does not emit it: the row count is not in the schema, and a
generated setval reading the column’s own max is a full scan written into a
migration by something that cannot see the table’s size. Getting this wrong is a
duplicate key on the next insert rather than a slow migration, which is why it
is named loudly instead of patched quietly.
Consequences
Section titled “Consequences”Buys. The reporting platform goes from 183/184 tables describable to 184, and from two blocked modules to none in the shared core. An append-only log keeps the key it should have. The index over a serial column stops falling with it. And a schema that already has one of these can be adopted without a migration whose only justification is sqlb.
Costs.
A fourth thing a column can be, in a switch that has no compiler behind it.
Array, Size, Dim and now Auto. A site that ignores Auto keeps
compiling and renders a plain integer — which is exactly how the DDL guard was
proven, and exactly why it had to be.
Two spellings for one idea, permanently. Nobody gets to stop knowing which one a schema uses, and the transition between them is a real migration with a real hazard. The alternative was refusing the spelling every adoption target actually has.
A derived sequence name. Named above; small, and visible only in a database rebuilt from scratch rather than migrated.
What would change our mind
Section titled “What would change our mind”A sequence name that has to survive. If a project turns up whose sequence
names are load-bearing — referenced by nextval() in application SQL, or by a
grant — then Auto grows a name field the way a check grew CheckName. The
trigger is a real report, not the theoretical possibility, because the field
costs a spelling in every declaration that has one.
A shared sequence. Two columns drawing from one sequence is a thing people
do, and nothing here can say it: Auto is per column and the name is derived.
That would need the field above plus a way to declare the sequence as an object,
which is a larger decision than this one and should be its own record.
Postgres deprecating serial. It has been discouraged for years and
removed never. If it is ever actually removed, the serial constructors become a
compatibility shim for reading old databases and the identity spelling is the
only one that renders.
Cost of change
Section titled “Cost of change”Adding to it is cheap. A sequence name, a start value, an increment — each
is a field on Auto with a zero value meaning what happens today, and none of
them changes a declaration that does not use it.
Removing a spelling is not. Serial, BigSerial, SmallSerial,
.Serial(), .Identity() and .IdentityAlways() are public API on the schema
package, which compatibility.md governs. Withdrawing one
before 1.0 is a mechanical edit in the release notes; after it, a major version.
Changing which spelling a constructor renders is worse than either. A schema
declaring BigSerial that started emitting an identity would produce a
migration proposing to drop a sequence and add an identity on every table that
uses it — a table-level change nobody asked for, generated by a version bump.
That is why the two are separate spellings rather than one constructor with a
rendering strategy behind it.