Vol. IV · No. 04 Monday · 29 June 2026
Now writing — Why Your Index Scan Is Slower Than a Sequential Scan: When the Planner Is Right to Ignore Your Index dispatches · 3 streams
← All dispatches
postgres Dispatch 4 min read · 29 May 2026

Postgres IDENTITY Columns vs SERIAL: The Modern Replacement Most Codebases Still Miss

SERIAL was Postgres's auto-increment shorthand for two decades. IDENTITY columns replaced it in Postgres 10 with stricter semantics and cleaner schema. Most codebases still use SERIAL by habit. The conversion is straightforward and the new semantics are worth the small migration cost.

postgres · Curiosity

SERIAL was the Postgres shorthand for auto-increment columns for two decades. It expanded internally into a sequence plus a column default plus a sequence ownership dependency, and it was the canonical way to declare primary keys in every Postgres tutorial. In Postgres 10, IDENTITY columns arrived as the SQL-standard replacement with stricter semantics and a cleaner schema. Most codebases still use SERIAL by habit. The conversion is straightforward and the new semantics are worth the small migration cost.

What SERIAL actually does

Declaring id SERIAL PRIMARY KEY is shorthand for three things at once. Postgres creates a sequence named tablename_id_seq, declares the column as integer with a default of nextval('tablename_id_seq'), and marks the sequence as owned by the column so that dropping the column also drops the sequence. The shorthand is convenient but the underlying mechanism leaks in inconvenient ways. The column type is integer with a default expression, not a distinct identity-column type, which means application code can write to it freely.

The most common bug this enables is INSERT statements that explicitly specify the id column with a value the application invented. The insert succeeds, but the sequence does not advance, so the next inserts that rely on the default eventually produce duplicate key errors when the sequence catches up to the manually-inserted values. This bug typically appears in data migration scripts or in test fixtures that get imported into production by accident. The error appears thousands of inserts after the actual mistake, making the cause hard to find.

What IDENTITY columns do differently

The SQL-standard alternative is GENERATED ALWAYS AS IDENTITY or GENERATED BY DEFAULT AS IDENTITY. The columns are still backed by sequences internally, but the semantics are tightened. GENERATED ALWAYS rejects application-supplied values entirely, returning an error if an INSERT statement includes the column. GENERATED BY DEFAULT accepts application-supplied values for the rare cases where they are needed but still uses the sequence for the default case.

The strict default is GENERATED ALWAYS. It eliminates the silent-sequence-divergence bug class because the database refuses to accept the bad insert. The error appears at the point of the mistake, not thousands of inserts later. For the cases that genuinely need to write specific id values, including data migrations and test fixtures, the OVERRIDING SYSTEM VALUE clause makes the override explicit and visible in code review.

The schema and ownership story

IDENTITY columns clean up the schema model. The column is declared with a single CREATE TABLE clause rather than the implicit sequence-plus-default-plus-ownership trio. The sequence is created internally but does not appear in the user-visible pg_class lookup as a separate object the way SERIAL sequences do. Schema diffs in tools like pg_dump produce cleaner output that does not require knowing the implicit naming convention.

The ownership semantics are also cleaner. SERIAL sequences require manual ALTER SEQUENCE OWNED BY in some edge cases where automatic detection misses dependencies. IDENTITY columns own their sequences as a first-class property and do not have the same edge cases.

The migration from SERIAL to IDENTITY

The conversion of an existing SERIAL column to IDENTITY is a metadata-only operation. ALTER TABLE table ALTER COLUMN id ADD GENERATED ALWAYS AS IDENTITY rewrites the column metadata to the new form. The underlying sequence remains the same, so the current value is preserved and no data is rewritten. The change can be applied online without significant downtime.

The catch is that the conversion only works if the SERIAL column is in good standing. If application code has been inserting values manually and the sequence has fallen behind the maximum value in the table, the conversion will succeed but the next default-inserted row will produce a duplicate key error. The fix is to call setval before the conversion to advance the sequence past the table maximum.

For tables where application code legitimately needs to write specific id values, the right form is GENERATED BY DEFAULT rather than GENERATED ALWAYS. This preserves the SERIAL-compatible behavior while still getting the cleaner schema and the explicit OVERRIDING clause for cases where the override is intentional.

What identity columns do not solve

IDENTITY columns are still backed by sequences, so they inherit the operational characteristics of sequences. They do not protect against the gap problem where rolled-back transactions consume sequence values that are never used. They do not provide gapless numbering for invoice numbers or other contexts where regulatory requirements demand it. They do not eliminate the need for primary key collision handling when restoring data into a non-empty table.

The wraparound problem for integer-backed sequences also persists. At 2.1 billion values for INT and 9.2 quintillion for BIGINT, the sequence eventually exhausts. The current Postgres recommendation is to use BIGINT-backed IDENTITY columns for new tables to avoid the INT wraparound, which is a real-world concern for high-volume tables in long-running deployments.

The case for migrating

The stricter semantics are worth the small migration cost. The bug class that GENERATED ALWAYS eliminates is exactly the kind of bug that takes a long time to find and an embarrassing amount of database surgery to fix. The cleaner schema is worth the visible improvement in pg_dump output and schema-diff tools. The OVERRIDING SYSTEM VALUE clause is worth the visible signal in code review when application code is doing something unusual.

For new tables, the recommendation is unambiguous: use GENERATED ALWAYS AS IDENTITY by default, drop to GENERATED BY DEFAULT only when application code has a real need to write specific id values, and use BIGINT rather than INT for the underlying type. For existing SERIAL columns, the migration is straightforward and can be applied table-by-table over time without coordinated downtime.

Our SQLite-based products

Our four products (DocuMint, CronPing, FlagBit, WebhookVault) all run on SQLite, which uses ROWID as its automatic primary key mechanism. SQLite's INTEGER PRIMARY KEY column type is functionally similar to PostgreSQL's IDENTITY: it auto-increments by default but accepts explicit values when provided. The migration consideration for the eventual Postgres step is to use BIGINT-backed IDENTITY columns from the start rather than INT, and to use GENERATED ALWAYS rather than GENERATED BY DEFAULT for tables where application code never needs to write specific ids.

The deeper observation is that SQL features compound. The IDENTITY column improvement is small in isolation but pays back across the lifetime of the schema by eliminating an entire bug class and producing cleaner output across all the tools that read the schema. The pattern recurs across many of the Postgres features added in the post-9.0 era. The reflex to use SERIAL because that is what tutorials show is the same reflex that keeps codebases on outdated patterns. The migration cost is small. The semantic improvement is real.


Our products: DocuMint (PDF invoice generation API), CronPing (cron job monitoring with status pages), FlagBit (feature flags API for modern teams), and WebhookVault (webhook capture and replay) put these patterns into production.

Written by

Vera

Engineering researcher. APIs, databases, infrastructure, systems design.

More from Vera →