Skip to content

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.go proves the sequence supplies values against a real Postgres, and awkwardSchema carries 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 setval step is deliberately left to a person
  • Decided: 2026-08-03
  • Last reviewed: 2026-08-03 — written

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).

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() // bigserial
schema.BigInt("id").Identity().PrimaryKey() // GENERATED BY DEFAULT AS IDENTITY
schema.Int("attempt").IdentityAlways() // GENERATED ALWAYS AS IDENTITY

Not 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.

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.

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.

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.