Filter concepts by levelShowing all levels.

PostgreSQL · Section 12

Sequences and Identity Columns

Level
intermediate
Read
26 min
Concepts
5

A sequence as a genuinely standalone database object generating atomic, lock-free incrementing values, IDENTITY as the modern standard-SQL syntax built on the same mechanism (with a real behavioral choice SERIAL never offered), why sequence gaps are a normal and expected trade-off rather than corruption, sequence advancement's deliberate exemption from transactional rollback, and the concrete concurrency race that manual max(id)+1 logic actually has.

PostgreSQL overview

What is true here

  1. A sequence is its own standalone object with persistent state — SERIAL/IDENTITY just wire a column's DEFAULT to one.
  2. GENERATED ALWAYS AS IDENTITY rejects an explicit value by default; GENERATED BY DEFAULT allows one, matching SERIAL.
  3. Sequence advancement is deliberately non-transactional — gaps from rollbacks or failed inserts are normal, not corruption.
  4. nextval() is atomic; max(id) + 1 is a read-then-write race that surfaces only under real concurrency.
  5. Never infer a row count or gap-free numbering from sequence/id values.

What you will be able to do

  • Explain what a sequence actually is, independent of any table
  • Choose between GENERATED ALWAYS and GENERATED BY DEFAULT based on whether explicit ids should ever be allowed
  • Explain why sequence gaps are normal and design schemas/reports that do not assume contiguity
  • Recognize and avoid the max(id)+1 concurrency race in favor of a sequence-backed column
From a standalone object to the bug it prevents
the trade thatavoids this bug

A standalone sequence

nextval() — atomic, lock-free

Gaps + non-transactional

both deliberate, both normal

The alternative: max(id)+1

a real read-then-write race

  • A standalone sequence — nextval() — atomic, lock-free
    • leads to Gaps + non-transactional
  • Gaps + non-transactional — both deliberate, both normal
    • leads to The alternative: max(id)+1 (the trade that avoids this bug)
  • The alternative: max(id)+1 — a real read-then-write race

What a sequence is

A standalone object, and the modern standard-SQL syntax built on top of it.

Sequences and Generated Identifiers

corebeginner

A sequence is its own database object that generates a new, incrementing integer every time it is asked, via nextval(). It exists independently of any table — a column with a DEFAULT of nextval('some_sequence') is simply a column that asks the sequence for the next value on every INSERT that omits it.

Think of it as

A sequence is not a property of a column — it is a separate, standalone object in the database that happens to be wired up as a column's default. This separation is what makes a sequence usable across multiple tables if desired, inspectable directly (SELECT * FROM some_sequence), and independently resettable — none of which would make sense if the increment logic were baked directly into the column itself rather than being its own object with its own state.

sql
CREATE SEQUENCE orders_id_seq;
CREATE TABLE orders (id INT DEFAULT nextval('orders_id_seq') PRIMARY KEY, total NUMERIC);

SELECT nextval('orders_id_seq');   -- can be called independently of any table

What we're doing: Query a sequence directly, independent of any table, to show it is a genuine standalone object with its own state.

sequence_demo.sqlsql
CREATE SEQUENCE orders_id_seq;
CREATE TABLE orders (id INT DEFAULT nextval('orders_id_seq') PRIMARY KEY, total NUMERIC);

INSERT INTO orders (total) VALUES (100), (50);

SELECT last_value FROM orders_id_seq;
SELECT nextval('orders_id_seq');  -- advances it further, with no INSERT involved at all
1–2
The sequence and the table are two separate objects — the column's DEFAULT is what connects them.
5
The sequence can be queried directly, like a table, showing its own current state.
6
nextval() advances the sequence even though no row was ever inserted — proving the sequence has state independent of orders.
Output
last_value
------------
          2

nextval
---------
       3

Why this works: last_value confirms the sequence has already advanced to 2 after two inserts consumed values 1 and 2 — but the second query, calling nextval() directly with no INSERT anywhere near it, advances the sequence to 3 anyway, which is only possible because the sequence is a genuinely independent object with its own persistent state, not merely an internal counter attached to the orders table's row count.

Assuming a sequence's current value reflects a table's actual row count

Wrong

sql
-- assuming SELECT last_value FROM orders_id_seq tells you how many orders exist
DELETE FROM orders WHERE id = 1;
SELECT last_value FROM orders_id_seq;  -- still shows the same value -- deleting a row does not roll back the sequence

Better

sql
SELECT count(*) FROM orders;
-- the actual, correct way to find the row count

What you see: Code that infers "how many orders exist" from a sequence's current value is wrong as soon as any row has ever been deleted, or any transaction using the sequence was ever rolled back — both leave gaps the sequence does not know or care about.

Why: A sequence tracks only "what value was handed out last," completely independent of what became of the rows that used those values — it has no awareness of deletes, rollbacks, or the table at all, since it is not actually part of the table's data. Row count must always come from counting rows directly (COUNT(*)), never inferred from a sequence, which the next concept's look at sequence gaps explores in more depth.

A sequence as an independent object a column defaults to

orders_id_seq

a standalone sequence object

nextval(...)

requests the next value, atomically

id column DEFAULT

used automatically on INSERT if omitted

  • orders_id_seq — a standalone sequence object
    • leads to nextval(...)
  • nextval(...) — requests the next value, atomically
    • leads to id column DEFAULT
  • id column DEFAULT — used automatically on INSERT if omitted

A sequence vs a column relying on it

A sequence vs a column relying on it
ObjectWhat it is
CREATE SEQUENCE orders_id_seqa standalone object generating incrementing integers
id INT DEFAULT nextval('orders_id_seq')a column that asks the sequence for its default value

Together

sql
CREATE SEQUENCE orders_id_seq;
CREATE TABLE orders (id INT DEFAULT nextval('orders_id_seq') PRIMARY KEY);

Remember: A sequence is its own standalone object with persistent state, independent of any table — SERIAL/IDENTITY are convenience syntax that create one automatically and wire a column's DEFAULT to it, not a different underlying mechanism.

See also: generated as identity · sequence gaps

GENERATED AS IDENTITY

standardintermediate

GENERATED ALWAYS AS IDENTITY creates a column backed by a sequence, the same underlying mechanism as SERIAL, but as standard SQL syntax rather than a PostgreSQL-specific shorthand — and GENERATED ALWAYS specifically rejects an explicit value on INSERT unless overridden, while GENERATED BY DEFAULT allows one.

Think of it as

IDENTITY is the SQL-standard vocabulary for exactly what SERIAL has always done in PostgreSQL — both wire a column's default to a sequence — but IDENTITY adds a real behavioral choice SERIAL never offered: whether an explicit value is normally allowed on INSERT at all. GENERATED ALWAYS says "the database decides, full stop" (an explicit value needs OVERRIDING SYSTEM VALUE to bypass). GENERATED BY DEFAULT says "the database decides only if you do not specify one" — the same permissive behavior SERIAL always had.

sql
CREATE TABLE orders (
    id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    total NUMERIC
);

INSERT INTO orders (total) VALUES (100);              -- id auto-assigned
INSERT INTO orders (id, total) OVERRIDING SYSTEM VALUE VALUES (999, 50);  -- explicit, opted in

What we're doing: Show GENERATED ALWAYS AS IDENTITY rejecting a plain explicit id, then succeeding only with OVERRIDING SYSTEM VALUE.

identity_always_demo.sqlsql
CREATE TABLE orders (id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, total NUMERIC);

INSERT INTO orders (total) VALUES (100);
-- succeeds -- id auto-assigned by the backing sequence

INSERT INTO orders (id, total) VALUES (999, 50);
-- rejected -- GENERATED ALWAYS refuses an explicit id by default

INSERT INTO orders (id, total) OVERRIDING SYSTEM VALUE VALUES (999, 50);
-- succeeds -- explicitly opted into overriding the identity behavior
3
Omitting id lets the backing sequence supply it, as expected.
6
Supplying id explicitly is rejected outright, since GENERATED ALWAYS means the database decides, by default.
9
OVERRIDING SYSTEM VALUE is the explicit, visible opt-in required to bypass that default.
Output
INSERT 0 1

ERROR:  cannot insert a non-DEFAULT value into column "id"
DETAIL:  Column "id" is an identity column defined as GENERATED ALWAYS.
HINT:  Use OVERRIDING SYSTEM VALUE to override.

INSERT 0 1

Why this works: GENERATED ALWAYS is a deliberate stronger guarantee than SERIAL ever provided — it makes bypassing the sequence a visible, explicit act (OVERRIDING SYSTEM VALUE) rather than something that can happen accidentally by simply including the column in an INSERT's column list. This matters specifically for migration scripts or bulk-loading tools that might otherwise supply an id value out of habit, potentially creating id collisions with the sequence's future output — GENERATED ALWAYS catches that class of mistake at the database level instead of allowing it silently.

Choosing GENERATED BY DEFAULT (or plain SERIAL) when the stronger GENERATED ALWAYS guarantee was actually wanted

Wrong

sql
CREATE TABLE orders (id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY);
-- a migration script accidentally supplies an explicit id -- silently accepted, no warning

Better

sql
CREATE TABLE orders (id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY);
-- the same accidental explicit id is now rejected outright, surfacing the bug immediately

What you see: A future explicit id value collides with one the sequence later generates, causing a duplicate key error much later and far removed from the original code that supplied the explicit value — a classic delayed-symptom bug.

Why: GENERATED BY DEFAULT silently accepts any explicit value supplied, exactly like SERIAL always has, which means a stray explicit id in some code path (a migration, a bulk import, a copy-paste from another table's logic) goes unnoticed until the sequence eventually catches up to that same value and collides with it. GENERATED ALWAYS converts that same mistake into an immediate, loud rejection at the moment the stray value is inserted — closer to the source of the bug rather than a confusing collision much later.

ALWAYS vs BY DEFAULT

ALWAYS vs BY DEFAULT
DeclarationExplicit value on INSERT?
GENERATED ALWAYS AS IDENTITYrejected, unless OVERRIDING SYSTEM VALUE is used
GENERATED BY DEFAULT AS IDENTITYallowed — falls back to the sequence only if omitted
SERIAL (legacy shorthand)always allowed — behaves like BY DEFAULT

Together

sql
CREATE TABLE orders (id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, total NUMERIC);

Remember: IDENTITY is the standard-SQL equivalent of SERIAL, backed by the same sequence mechanism — GENERATED ALWAYS rejects an explicit value by default (requiring OVERRIDING SYSTEM VALUE), while GENERATED BY DEFAULT permits one silently, matching SERIAL's historical behavior.

See also: sequences and generated identifiers · uuid vs integer identifiers

Advertisement

Two real quirks

Gaps and non-transactional advancement — both deliberate, both normal.

Sequence Gaps and Why They Are Normally Acceptable

standardintermediate

A sequence can produce gaps — id 5 followed by id 8, with 6 and 7 never appearing in the table — because a rolled-back transaction, a failed insert, or simply calling nextval() without using it all permanently consume that value. This is expected, normal behavior, not corruption, and application logic should never assume sequence values are gap-free.

Think of it as

A sequence guarantees uniqueness and, under normal operation, increasing order — it never promises "no gaps," and cannot, because sequence advancement is deliberately non-transactional. If it rolled back along with a failed transaction, two concurrent transactions could both receive the same "next" value while waiting to see if the other commits, defeating the entire purpose of a sequence as a fast, lock-free way to generate unique values. Accepting gaps is the price of that concurrency guarantee.

sql
SELECT max(id) - min(id) + 1 AS id_span, count(*) AS actual_rows FROM orders;
-- if id_span > actual_rows, gaps exist -- this is normal, not a bug

What we're doing: Reproduce a sequence gap directly by rolling back a transaction that consumed an id, then confirm the next insert skips that value permanently.

sequence_gap_demo.sqlsql
CREATE TABLE orders (id SERIAL PRIMARY KEY, total NUMERIC);

BEGIN;
INSERT INTO orders (total) VALUES (100);
ROLLBACK;

INSERT INTO orders (total) VALUES (200);

SELECT * FROM orders;
3–5
This transaction consumes id 1 via the sequence, then rolls back — the row is gone, but the sequence has already moved past 1.
7
This insert gets id 2, not id 1 — the sequence does not know or care that the previous consumer rolled back.
Output
id | total
---+-------
 2 |   200
(1 row)

Why this works: The sequence advanced to 1 the instant nextval() was called during the first INSERT, entirely independent of whether that transaction eventually committed or rolled back — sequence state changes are not part of the transaction's undoable work, by design. This is exactly why gaps happen: the rollback undid the row insertion, but it could not and did not undo the sequence's advancement, since reverting that would require the sequence to somehow know no other concurrent transaction had already been handed a later value in the meantime.

Relying on sequential ids being gap-free for a business calculation (like invoice numbering)

Wrong

sql
-- assuming: "id 47 exists, so exactly 47 orders have ever been placed"
SELECT max(id) FROM orders;  -- used as a proxy for "total orders ever placed"

Better

sql
SELECT count(*) FROM orders;
-- the only correct way to count actual rows -- id values are not a reliable proxy

What you see: A business report claiming "47 orders have been placed" based on the highest id value turns out to overcount, because some of those 47 id values were consumed by rolled-back transactions or failed inserts that never produced a real order.

Why: A sequence's only guarantees are uniqueness and, under normal single-threaded use, increasing order — it makes no promise about contiguity, and cannot, for the concurrency reasons already established. Any business logic that needs an actual count, or a gap-free sequential number (like a legally required invoice sequence), needs a different mechanism entirely — typically an application-level or table-level counter with its own explicit, transactional gap-prevention logic, which is a fundamentally different (and slower) guarantee than what a plain sequence provides.

Common causes of a sequence gap

Common causes of a sequence gap
CauseResult
A transaction that inserts a row then rolls backthe consumed id is never reused
A failed INSERT (e.g. a constraint violation) after the DEFAULT already called nextval()the id is consumed even though no row was stored
Calling nextval() directly, outside any INSERTthe value is consumed with no corresponding row at all

Together

sql
BEGIN;
INSERT INTO orders (total) VALUES (100);  -- consumes id 5
ROLLBACK;
INSERT INTO orders (total) VALUES (200);  -- gets id 6, NOT id 5 -- id 5 is gone forever

Remember: A sequence guarantees uniqueness and increasing order, never contiguity — gaps from rollbacks, failed inserts, or direct nextval() calls are normal and expected, not corruption. Never infer a row count or a gap-free numbering from sequence/id values.

See also: sequences and generated identifiers · sequences and rollback

How Sequences Behave With Rollback

standardintermediate

A sequence's nextval() call is never rolled back, even if the surrounding transaction is — this is a deliberate exception to PostgreSQL's normal rule that a rolled-back transaction undoes everything it did. Sequences are specifically designed this way to avoid blocking concurrent transactions.

Think of it as

Almost everything a transaction does is undoable on ROLLBACK — inserts, updates, deletes, even DDL. Sequences are a deliberate, documented exception, because making them transactional would require serializing every concurrent nextval() call: transaction A would have to hold its consumed value pending, and transaction B could not safely receive the "next" value until A either committed or rolled back, since rolling back would mean A's value should be reusable. That serialization would destroy the entire performance benefit a sequence exists to provide — fast, non-blocking, lock-free ID generation under high concurrency.

sql
BEGIN;
SELECT nextval('orders_id_seq');
ROLLBACK;
SELECT nextval('orders_id_seq');  -- continues from where it left off, unaffected by the rollback

What we're doing: Call nextval() directly inside a transaction that is then rolled back, and confirm the very next call continues forward rather than reverting.

sequence_rollback_demo.sqlsql
CREATE SEQUENCE orders_id_seq;

BEGIN;
SELECT nextval('orders_id_seq') AS value_in_rolled_back_txn;
ROLLBACK;

SELECT nextval('orders_id_seq') AS value_after_rollback;
3–5
This transaction consumes a sequence value and then rolls back entirely.
7
The next call continues forward from the already-consumed value, not from before it — the rollback had no effect on the sequence.
Output
value_in_rolled_back_txn
---------------------------
                         1

value_after_rollback
----------------------
                     2

Why this works: PostgreSQL treats sequence objects as living outside the normal transactional undo log specifically so that a nextval() call takes effect the instant it runs, visible to every other concurrent session immediately, rather than being provisional pending the calling transaction's outcome. If sequences behaved transactionally, two concurrent transactions each calling nextval() would create a genuine conflict: whichever one eventually rolled back would need its value "returned," but the other transaction might already be relying on having received a distinct, later value — the non-transactional design sidesteps this entire class of problem.

Expecting a sequence value to be reclaimed after a rollback, for an application counting on tight, contiguous ids

Wrong

sql
-- application logic that treats a rolled-back insert's id as "available again" for reuse
-- and later tries to manually reuse it, assuming no one else has claimed it since

Better

sql
-- accept that ids are unique but not contiguous, and never manually reuse a consumed value
-- if gap-free numbering is a hard requirement, use a dedicated counter table with explicit locking instead

What you see: Application code attempting to "reuse" an id from a failed transaction either creates a duplicate key conflict (if the sequence has already moved past it via other activity) or, worse, silently reuses a value another concurrent transaction has already been issued.

Why: A sequence value, once issued via nextval(), is permanently consumed regardless of the outcome of the transaction that requested it — there is no safe way to determine whether "unused" ids from a rolled-back transaction are truly available, because other concurrent activity may have already assumed uniqueness based on the sequence never reissuing a value. Manually reusing a consumed sequence value bypasses the exact uniqueness guarantee the sequence exists to provide.

What rolls back vs what does not

What rolls back vs what does not
Action inside a rolled-back transactionRolled back?
INSERT/UPDATE/DELETEyes — fully undone
CREATE TABLE / DDLyes — fully undone
nextval() (directly or via DEFAULT)no — the consumed value stays consumed

Together

sql
BEGIN;
SELECT nextval('orders_id_seq');  -- e.g. returns 5
ROLLBACK;
SELECT nextval('orders_id_seq');  -- returns 6, not 5

Remember: Sequence advancement is deliberately exempt from transactional rollback — a consumed value stays consumed even if the transaction that requested it rolls back, because making sequences transactional would require serializing concurrent nextval() calls and defeat their entire performance purpose.

See also: sequence gaps · application level max id plus one is unsafe

Advertisement

The alternative that fails

The concrete concurrency race manual max(id)+1 logic has, and why a sequence avoids it.

Why Application-Level max(id) + 1 Is Unsafe

coreintermediate

SELECT max(id) + 1 FROM t, then inserting with that computed value, has a race condition: two concurrent transactions can both read the same max(id), both compute the same "next" value, and both attempt to insert it — one succeeds, one fails (or worse, both succeed if there is no unique constraint). A sequence's nextval() has no such window, because it is atomic by design.

Think of it as

The bug is a classic read-then-write race: reading max(id) and writing a new row based on it are two separate steps, and nothing stops another transaction from doing the exact same read in between. A sequence collapses "read the current value" and "claim the next one" into a single atomic operation — there is no window for a second caller to observe the same value before the first caller's claim is recorded, which is precisely the property max(id)+1 lacks.

sql
-- the correct, safe alternative
CREATE TABLE orders (id SERIAL PRIMARY KEY, total NUMERIC);
INSERT INTO orders (total) VALUES (100);  -- id assigned atomically

What we're doing: Simulate the max(id)+1 race by manually running both halves of the pattern from two "concurrent" sessions, showing the collision.

max_id_race.sqlsql
CREATE TABLE orders (id INT PRIMARY KEY, total NUMERIC);
INSERT INTO orders VALUES (5, 100);

-- Session A:
SELECT max(id) FROM orders;  -- reads 5, computes next = 6

-- Session B, running concurrently, before Session A's INSERT commits:
SELECT max(id) FROM orders;  -- ALSO reads 5 (Session A hasn't inserted yet), computes next = 6

-- Session A inserts:
INSERT INTO orders VALUES (6, 200);  -- succeeds

-- Session B inserts, using its own independently-computed value of 6:
INSERT INTO orders VALUES (6, 300);  -- fails: duplicate key
5
Session A reads max(id) = 5, before either session has inserted a new row.
8
Session B reads the SAME max(id) = 5, because Session A has not committed its insert yet — this is the actual race window.
11–15
Both sessions independently computed 6 as "the next id" — only one INSERT can succeed against a PRIMARY KEY.
Output
INSERT 0 1

ERROR:  duplicate key value violates unique constraint "orders_pkey"
DETAIL:  Key (id)=(6) already exists.

Why this works: Both sessions' SELECT max(id) statements ran before either session's INSERT was visible to the other — this is not a hypothetical edge case, it is the normal, expected behavior of two genuinely concurrent transactions each reading committed data as it existed at the time of their read. Because computing "the next id" and inserting a row using it are two separate statements with a real time gap between them, any other transaction running the same two steps in that gap computes the identical "next" value — the PRIMARY KEY constraint here at least turns the collision into a visible error rather than silently allowing two different orders to share an id.

Reaching for max(id) + 1 to avoid "wasting" sequence values or to get contiguous ids

Wrong

sql
INSERT INTO orders (id, total) VALUES ((SELECT max(id) + 1 FROM orders), 100);
-- motivated by wanting tight, gap-free ids -- introduces a real concurrency bug instead

Better

sql
CREATE TABLE orders (id SERIAL PRIMARY KEY, total NUMERIC);
INSERT INTO orders (total) VALUES (100);
-- accept that ids may have gaps (per sequence-gaps.js) in exchange for actual concurrency safety

What you see: Under light, single-user testing, max(id) + 1 appears to work perfectly and produces satisfyingly gap-free ids — then production, with real concurrent traffic, starts throwing intermittent duplicate-key errors or, without a unique constraint, silently creates two rows sharing an id.

Why: The race window in max(id) + 1 only manifests under genuine concurrency — a single developer testing sequentially never triggers two simultaneous reads of the same max(id), which is exactly why this bug is easy to miss in development and only surfaces under real production load. A sequence's atomicity guarantee exists specifically to remove this entire class of bug, at the cost of accepting non-contiguous ids — a trade this course's own sequence-gaps concept already establishes as normal and expected.

The max(id)+1 race condition, two concurrent transactions
Transaction A
orders table
Transaction B
  1. 1. SELECT max(id) → 5
  2. 2. SELECT max(id) → 5 (same value)
  3. 3. INSERT id=6
  4. 4. INSERT id=6 → conflict or duplicate
  1. Transaction A → orders table: SELECT max(id) → 5
  2. Transaction B → orders table: SELECT max(id) → 5 (same value)
  3. Transaction A → orders table: INSERT id=6
  4. Transaction B → orders table: INSERT id=6 → conflict or duplicate

max(id) + 1 vs nextval() under concurrency

max(id) + 1 vs nextval() under concurrency
ApproachAtomic?Safe under concurrent inserts?
SELECT max(id) + 1, then INSERTno — two separate stepsno — race condition
nextval('seq'), or a SERIAL/IDENTITY columnyes — one indivisible operationyes — by design

Together

sql
-- unsafe under concurrency
INSERT INTO orders (id, total) VALUES ((SELECT max(id) + 1 FROM orders), 100);

-- safe
INSERT INTO orders (total) VALUES (100);  -- id from a SERIAL/IDENTITY default

Remember: max(id) + 1 is a read-then-write race: two concurrent transactions can read the same max(id) and compute the same "next" value before either inserts. A sequence's nextval() is atomic — no window exists for that race, which is exactly why sequences (accepting gaps) exist instead of manual max+1 logic (which does not actually guarantee uniqueness).

See also: sequences and rollback · sequence gaps

Advertisement