A sequential identifier makes nearby values easy to guess. Seeing order number four thousand gives an observer a useful place to probe, and collecting identifiers over time may reveal allocation activity. It does not establish that four thousand orders exist, that all numbers represent successful purchases, or that the largest identifier committed last. Those conclusions require information that a sequence does not contain.

When it breaks

PostgreSQL sequences allocate values outside ordinary transaction rollback. An aborted insert can consume a number permanently. Deleted rows, reserved cache ranges, explicit sequence changes, and multiple writers further separate the counter from the table’s current population. Using the largest identifier as a customer count produces misleading business reports even without malicious activity.

Allocation order also differs from commit order. One transaction can obtain a smaller number and wait while another obtains a larger number and commits immediately. A consumer that treats increasing identifiers as a complete committed event stream can advance past the smaller row before that row becomes visible. A numeric key is not a change-data-capture position or a complete ordering of business events.

Random identifiers reduce easy enumeration but do not grant access control. A UUID can appear in logs, shared links, or another response. Anyone who learns it must still pass the resource’s authorization check. UUIDv4 contains random bits; UUIDv7 starts with a millisecond timestamp field and is designed for time ordering. Its prefix is not random, and its timestamp is not proof of database commit time.

Check it quickly

Use PostgreSQL 18 with two administrator psql sessions, A and B, against a disposable database. The UUID extraction functions below require this version. Ensure the schema and two role names are unused. Begin with autocommit enabled and the usual Read Committed isolation. In B, create the fixture:

CREATE SCHEMA article_ids;
CREATE ROLE article_id_alice NOLOGIN;
CREATE ROLE article_id_bob NOLOGIN;
SET search_path=article_ids,public;
CREATE TABLE objects (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  public_id uuid NOT NULL DEFAULT gen_random_uuid() UNIQUE,
  owner text NOT NULL,
  label text NOT NULL
);

In A, allocate the first identifier without committing:

SET search_path=article_ids,public;
BEGIN;
INSERT INTO objects (owner,label) VALUES ('article_id_alice','allocated first') RETURNING id;

In B, insert the second row and inspect visibility:

INSERT INTO objects (owner,label) VALUES ('article_id_bob','committed first') RETURNING id;
SELECT array_agg(id ORDER BY id) FROM objects;

B returns identifier two and sees only {2}. A has already obtained one, but its uncommitted row is invisible to B. Now commit A:

COMMIT;

Continue in B, making a deliberate rollback gap and inspecting both UUID versions:

SELECT array_agg(id ORDER BY id) FROM objects;
BEGIN;
INSERT INTO objects (owner,label) VALUES ('article_id_alice','rolled back') RETURNING id;
ROLLBACK;
INSERT INTO objects (owner,label) VALUES ('article_id_alice','after gap') RETURNING id;
SELECT max(id),count(*) FROM objects;
SELECT bool_and(uuid_extract_version(public_id)=4) FROM objects;
WITH sample AS MATERIALIZED (SELECT uuidv7() AS value)
SELECT uuid_extract_version(value),uuid_extract_timestamp(value) IS NOT NULL FROM sample;

The outputs include {1,2}, the abandoned identifier three, the next identifier four, and 4|3: maximum four, only three rows. The final checks report true for version four and 7|t for version seven with an extractable timestamp. Exact UUID values change on each run and are not expected constants.

Still in B, save Bob’s UUID in a client variable and query as Alice before installing any row policy:

SELECT public_id AS bob_id FROM objects WHERE owner='article_id_bob' \gset
GRANT USAGE ON SCHEMA article_ids TO article_id_alice,article_id_bob;
GRANT SELECT ON objects TO article_id_alice,article_id_bob;
SET ROLE article_id_alice;
SELECT count(*) FROM objects WHERE public_id=:'bob_id'::uuid;
RESET ROLE;

Alice sees one matching row despite knowing only Bob’s random public identifier. Enable ownership filtering and repeat the identical lookup:

ALTER TABLE objects ENABLE ROW LEVEL SECURITY;
CREATE POLICY owner_only ON objects FOR SELECT USING (owner=current_user);
SET ROLE article_id_alice;
SELECT count(*) FROM objects WHERE public_id=:'bob_id'::uuid;
SELECT array_agg(id ORDER BY id) FROM objects;
RESET ROLE;
SET ROLE article_id_bob;
SELECT count(*) FROM objects WHERE public_id=:'bob_id'::uuid;
RESET ROLE;

Now Alice receives zero matches and sees only identifiers one and four, while Bob receives one match for his own identifier. The changed result comes from the policy, not from changing UUID generation. These test roles are neither table owners nor superusers; privileged roles can bypass row security. Clean up in B after A has committed:

DROP SCHEMA article_ids CASCADE;
DROP ROLE article_id_alice;
DROP ROLE article_id_bob;

Choose identifiers and permissions separately

An internal sequence plus a public random identifier can support efficient joins and less predictable URLs. It also adds another unique index and another identifier to maintain. Evaluate that cost against the actual enumeration and disclosure requirements. A time-ordered UUID may improve insertion locality in some workloads, but exposes time information and does not automatically improve every database index.

Authorize every lookup using the authenticated principal and requested object, including alternative lookup routes. The demonstration maps principals to database roles for clarity; an application must establish its own trusted identity context rather than accept an owner name supplied by the requester. Measure totals with the appropriate business query, and use a documented event or replication position when completeness across concurrent commits matters.

Sources