PG.L6.SERIAL_NOT_IDENTITY — A key generated by serial rather than by an identity column
- Category: idiom
- Level: 6
- Confidence: deterministic
- Downtime class: none — nothing here is a schema change
- Stability: stable
- Suites: audit
- Applies to: PostgreSQL
serial is not a type, which is why no type check finds it
id bigserial looks like a type declaration. It is a macro, and it expands into four separate
objects:
CREATE SEQUENCE orders_id_seq;
CREATE TABLE orders (id bigint NOT NULL DEFAULT nextval('orders_id_seq'));
ALTER SEQUENCE orders_id_seq OWNED BY orders.id;
Afterwards the catalog reports the column as bigint — character for character what
bigint GENERATED BY DEFAULT AS IDENTITY reports. The construction leaves its signature only in
pg_depend, as an auto dependency from the sequence to the column.
That separateness is the whole issue, and it shows up in ordinary work rather than in theory.
The default is detachable, and nothing records that it was there
ALTER TABLE orders ALTER COLUMN id DROP DEFAULT;
One statement, no error, and the column keeps every other property of a key: NOT NULL, primary
key, bigint. It has simply stopped generating values, and the next insert without an explicit id
fails with a null-violation on a column nobody touched. Nothing in the schema afterwards says this
used to be a serial.
An identity column cannot be undone by accident. Removing its generation takes a statement that says what it is doing:
ALTER TABLE orders ALTER COLUMN id DROP IDENTITY;
The sequence carries its own permissions
This is the single most common serial surprise, and it appears only at runtime, under a role that
is not the owner:
ERROR: permission denied for sequence orders_id_seq
A role granted INSERT on orders still cannot insert, because the default calls nextval on a
separate object it was never granted USAGE on. Applications that run migrations as one role
and serve traffic as another meet this on the first insert after a deploy.
An identity column's sequence is an internal dependency. There is no second grant, because there is no second object to grant.
An explicit value is accepted, and collides later
serial produces at most the equivalent of GENERATED BY DEFAULT: a default applies only when no
value is supplied, so an explicit id is taken as given, the sequence is not advanced, and
nothing is reported.
INSERT INTO orders (id, total) VALUES (500, 19.99); -- accepted; sequence still at 1
The collision arrives whenever the sequence eventually reaches 500 — on a row nobody was writing at
the time, as a duplicate-key error with no visible cause. GENERATED ALWAYS AS IDENTITY refuses the
explicit value outright, which is the behavior most people believed they already had.
Flagged
Schema::create('orders', function (Blueprint $table) {
$table->bigIncrements('id');
});
Preferred
Schema::create('orders', function (Blueprint $table) {
$table->id();
});
Laravel's id() produces an identity column on PostgreSQL; bigIncrements() produces a serial.
For a table that already exists, the conversion is three statements plus a cleanup, and the middle
one is the one people forget — without it the new identity sequence starts at 1 and collides with
every row already there:
ALTER TABLE orders ALTER COLUMN id DROP DEFAULT;
ALTER TABLE orders ALTER COLUMN id ADD GENERATED ALWAYS AS IDENTITY;
SELECT setval(pg_get_serial_sequence('orders', 'id'), (SELECT max(id) FROM orders));
DROP SEQUENCE orders_id_seq; -- the old one, now referenced by nothing
All four are catalog-only operations: no table is rewritten and no row is touched.
Three neighbors this refuses to confuse with a serial
The detection joins three catalog facts — the identity marker, the default's shape, and the
dependency — because three ordinary, deliberate shapes sit right next to serial and each one would
be a false positive. Measured on PostgreSQL 18.4:
| Shape | attidentity | Default | pg_depend | Reported |
|---|---|---|---|---|
bigserial | empty | nextval('t_id_seq') | a on this column | yes |
GENERATED … AS IDENTITY | a / d | none at all | i on this column | no |
| A shared or hand-managed sequence | empty | nextval('s') | nothing on this column | no |
| A sequence owned only for cleanup | empty | none | a on this column | no |
The third row is worth stating twice. A sequence somebody created by hand and defaults several
columns from is a design, not a serial that got away — and the dependency that proves it sits on
the owning column, which may be a sibling on the same table. A check asking "does this table own
a sequence" would report the borrowing column; this asks about the column.
What is answered as undetermined
A column that owns its sequence but draws from it through a default that is not the bare call:
ALTER TABLE orders ALTER COLUMN id SET DEFAULT COALESCE(nextval('orders_id_seq'), 1);
Both answers would be wrong here. An identity column cannot express that default, so recommending
the conversion would silently change what the column does; and staying quiet would report the table
as checked when the one question worth asking went unanswered. The finding says so, with the reason
sequence_ownership_unclear.
Why this is level 6 and not a defect
serial works. It has worked for two decades, Laravel's bigIncrements() still produces it, and a
schema full of it is not broken — which is exactly what level 6 is for.
The finding is an invitation to decide, not a verdict, and a project that decides to keep serial
has decided correctly for itself.
Sources
- PostgreSQL 18: serial types — states
that
serialis not a true type but a notational convenience that expands into a column, a sequence and a default, that the sequence is marked as owned by the column, and that a role needsUSAGEorSELECTon the sequence in addition to its privileges on the table. - PostgreSQL 18:
CREATE TABLE, identity columns —GENERATED ALWAYSrejects an explicit user value unlessOVERRIDING SYSTEM VALUEis given, whileGENERATED BY DEFAULTaccepts it.