A surrogate primary key separates row identity from a business value that can change. A customer’s email can be corrected while orders continue referencing the same customer identifier. This is useful when the business name is mutable or wide, but stability comes from schema and application policy. A generated number is not automatically immutable, secret, globally unique, or an authorization decision.

Generate identity and enforce uniqueness

PostgreSQL identity columns obtain default values from an associated sequence. GENERATED ALWAYS AS IDENTITY rejects ordinary explicit values, while GENERATED BY DEFAULT allows them. ALWAYS still permits an explicit OVERRIDING SYSTEM VALUE import. Neither form alone guarantees uniqueness; declare a PRIMARY KEY or UNIQUE constraint when duplicate identifiers must be rejected.

The primary key combines uniqueness with non-nullability and provides an index usable by references. Identity controls generation, while the constraint controls which stored values are legal. Keeping these responsibilities distinct matters during imports: a manually supplied value can collide with future generated values if the sequence is not reconciled. The sequence is not automatically advanced merely because a large identifier was inserted explicitly.

Preserve the business rule separately

A surrogate id does not stop duplicate business entities. If email is the unique login identifier, retain an explicit uniqueness constraint and decide the normalization rule. A plain text UNIQUE constraint follows the column’s comparison semantics; it does not silently implement the application’s desired case folding, whitespace handling, or mailbox rules. Make those decisions before migrating existing data.

Likewise, child tables need actual foreign keys if dangling references must be prevented. Renaming a non-key email leaves those references unchanged because they point to the id. This reduces the scope of that edit, but does not promise that every business rename becomes trivial: search indexes, audit records, external integrations, and cache invalidation may still depend on the changed value.

When it breaks

Treating sequence values as creation or commit order produces incorrect results under concurrency. Transactions can allocate numbers and commit in a different order, and aborted statements can leave gaps. Use an explicit timestamp and a deterministic tie-breaker for display ordering. A sequential identifier is also guessable; authorization must check the requesting principal and resource, even when a separate opaque public identifier is used.

Imports create another trap. Overriding identity generation can insert an id far ahead of the sequence without moving its current position. Eventually a generated insert may hit an existing primary key. Reconcile sequence state as part of a controlled import procedure, with concurrent writers excluded or otherwise coordinated. max(id) in an unrelated session is not a concurrency-safe allocation algorithm.

A migration can also remove the old natural-key constraint by accident. Audit duplicates and nulls before changing keys, retain required business uniqueness, and update every real reference. Do not add a surrogate solely because it is fashionable: a stable natural key may already express the intended contract without another column and index.

Check it quickly

Save this as identity.sql and run psql -X -v ON_ERROR_STOP=1 -f identity.sql in a disposable PostgreSQL 18 database. The experiment uses temporary tables and a final rollback, with no dependency on an existing users table.

\set ON_ERROR_STOP on
BEGIN;
CREATE TEMP TABLE users (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email text NOT NULL UNIQUE
);
CREATE TEMP TABLE orders (
  id integer PRIMARY KEY,
  user_id bigint NOT NULL REFERENCES users(id)
);
INSERT INTO users(email) VALUES ('a@example.test') RETURNING id AS owner_id \gset
INSERT INTO orders VALUES (1, :owner_id);
UPDATE users SET email='renamed@example.test' WHERE id=:owner_id;
SELECT u.id, u.email FROM orders o JOIN users u ON u.id=o.user_id;
DO $$ BEGIN
  INSERT INTO users(email) VALUES ('renamed@example.test');
  RAISE EXCEPTION 'duplicate email accepted';
EXCEPTION WHEN unique_violation THEN RAISE NOTICE 'duplicate email rejected';
END $$;
DO $$ BEGIN
  INSERT INTO users(id,email) VALUES (99,'manual@example.test');
  RAISE EXCEPTION 'explicit identity accepted without override';
EXCEPTION WHEN generated_always THEN RAISE NOTICE 'explicit identity rejected';
END $$;
INSERT INTO users(id,email) OVERRIDING SYSTEM VALUE VALUES (100,'import@example.test');
INSERT INTO users(email) VALUES ('next@example.test') RETURNING id;
DO $$ BEGIN
  IF (SELECT email FROM users u JOIN orders o ON u.id=o.user_id)
     IS DISTINCT FROM 'renamed@example.test' THEN
    RAISE EXCEPTION 'rename broke the reference';
  END IF;
  IF (SELECT id FROM users WHERE email='next@example.test') IS DISTINCT FROM 3 THEN
    RAISE EXCEPTION 'unexpected identity sequence position';
  END IF;
END $$;
ROLLBACK;

The order still resolves to user one after the email changes. A duplicate email is rejected, and that failed insert consumes sequence value two. An explicit id without an override is rejected. The controlled override inserts id one hundred, but the following generated row receives id three, demonstrating that the import did not reposition the sequence. Assertions check the surviving reference and exact sequence result for this isolated fixture.

These results were verified on PostgreSQL 18.6. The final rollback removes the test objects; it does not establish gap-free sequence behavior for ordinary application transactions. The example demonstrates why generation, primary-key enforcement, foreign-key integrity, business uniqueness, and access control need separate reasoning even when they all involve one identifier.

Sources