UUID vs BIGSERIAL as Primary Key in PostgreSQL: Which Should You Use?
Choosing a primary key in PostgreSQL is a foundational schema decision that affects storage, index efficiency, cache behavior, WAL volume, join performance, and application architecture. The two most common options—BIGINT generated by a sequence (BIGSERIAL / GENERATED ... AS IDENTITY) and UUID—have very different characteristics once tables grow beyond a few million rows.
This post examines the storage-engine mechanics unique to PostgreSQL, quantifies the performance differences with realistic benchmarks, supplies production-ready code patterns, and evaluates the practical alternatives.
PostgreSQL Storage Model: Heap + Separate Indexes
Unlike InnoDB (which uses a clustered primary-key index), PostgreSQL stores table data in a heap. Rows are placed according to the free-space map; there is no permanent physical ordering by primary key. The primary-key constraint is enforced by a separate B-tree index that stores the key value plus a TID (tuple identifier) pointing into the heap.
Consequences:
- Inserts into the table itself are largely unaffected by key randomness—new tuples simply go into free space.
- The primary-key index still suffers from random inserts: leaf-page splits, lower fill factor, poorer cache locality, and higher WAL traffic (especially when
full_page_writesis on). - Secondary indexes and foreign-key columns also store a copy of the primary key, so key width multiplies across the schema.
- Sequential keys keep the “right-hand side” of the B-tree hot, maximizing buffer-cache hits and minimizing page splits.
BIGSERIAL / IDENTITY Mechanics
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
...
);
- 8-byte signed integer (range ≈ 9 × 10¹⁸).
- Sequence object supplies values;
nextvalis extremely cheap. - Inserts land at the rightmost leaf of the primary-key index → near-perfect fill factor, minimal WAL amplification, excellent cache locality.
- Prefer the SQL-standard
GENERATED ... AS IDENTITYover the olderBIGSERIALsyntax (the latter is merely a convenience wrapper).
UUID Mechanics
PostgreSQL’s native uuid type is a 16-byte binary value. Generation options:
| Function / Method | Version | Ordering | Notes |
|---|---|---|---|
gen_random_uuid() |
v4 | Fully random | Built-in since PG 13 |
uuidv7() |
v7 | Time-ordered | Native in PostgreSQL 18+ |
| Extension / application | v7 | Time-ordered | For older major versions |
Random UUIDv4 keys produce scattered index inserts. UUIDv7 places a 48-bit Unix-millisecond timestamp in the most-significant bits, restoring near-sequential behavior while remaining globally unique and client-generatable.
Storage Footprint
| Key Type | Bytes | Index entry overhead (approx.) | Relative index size (1 M rows) |
|---|---|---|---|
BIGINT |
8 | ~16–24 bytes total | 1× |
UUID |
16 | ~24–32 bytes total | ≈ 1.5–2× |
Because every foreign key and secondary index stores the primary-key value, a 16-byte key versus an 8-byte key compounds across the entire schema. Narrow tables amplify the relative difference; wide tables mute it.
Performance Characteristics
Benchmarks from multiple independent sources (Cybertec, 2ndQuadrant/EDB-style tests, Ardent, QueryPlane, recent UUIDv7 measurements on PG 16–18) show a consistent pattern:
Insert Throughput (relative)
| Scenario | BIGINT / IDENTITY | UUIDv4 | UUIDv7 |
|---|---|---|---|
| Small table (fits in cache) | Baseline | 10–30 % slower | Near-parity |
| Medium (cache pressure) | Baseline | 30–60 % slower | 5–20 % slower |
| Large (index > shared_buffers) | Baseline | 2–4× slower | 10–30 % slower |
| High concurrency | Best | Worst (WAL + page splits) | Close to BIGINT |
Typical observed numbers on modern hardware (1 M–10 M row inserts):
- BIGINT: highest TPS, smallest WAL volume.
- UUIDv7: usually within 10–25 % of BIGINT.
- UUIDv4: noticeably slower and generates substantially more WAL (random leaf pages force more full-page images).
Index Size & Bloat
After loading ~10 M rows:
| Key Type | Typical PK index size | Bloat / fill-factor impact |
|---|---|---|
| BIGINT | Smallest | Minimal |
| UUIDv7 | ~1.4–1.6× BIGINT | Low |
| UUIDv4 | ~1.8–2.2× BIGINT | High (page splits leave half-empty pages) |
Query Latency
- Point lookups by primary key: BIGINT usually fastest; UUIDv7 is very close; UUIDv4 incurs extra buffer traffic once the index no longer fits in cache.
- Range scans /
ORDER BY id: sequential keys win decisively because of sequential index leaf access and better heap locality after clustering or natural insertion order. - Joins on the primary key: wider keys increase memory and CPU cost; the difference is measurable but rarely dramatic unless the working set is huge.
WAL Amplification
Random index inserts dirty many more leaf pages. With full_page_writes = on (the default and recommended setting), each newly dirtied page after a checkpoint writes a full 8 kB image into the WAL. Sequential keys keep the set of dirty pages tiny; random UUIDv4 can inflate WAL volume by an order of magnitude on large indexes.
Code Examples
Schema Definitions
-- Preferred sequential primary key (SQL standard)
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL,
amount NUMERIC(12,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- UUIDv7 primary key (PostgreSQL 18+)
CREATE TABLE orders_uuid (
id UUID PRIMARY KEY DEFAULT uuidv7(),
customer_id BIGINT NOT NULL,
amount NUMERIC(12,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Hybrid: sequential internal key + public UUID
CREATE TABLE orders_hybrid (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
public_id UUID NOT NULL DEFAULT uuidv7(),
customer_id BIGINT NOT NULL,
amount NUMERIC(12,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (public_id)
);
Generating UUIDv7 on Older PostgreSQL Versions
-- Pure-SQL UUIDv7 (works on PG 13+)
CREATE OR REPLACE FUNCTION uuidv7() RETURNS uuid
LANGUAGE sql VOLATILE AS $$
SELECT encode(
set_bit(
set_bit(
overlay(
uuid_send(gen_random_uuid())
placing substring(int8send((extract(epoch from clock_timestamp()) * 1000)::bigint) from 3)
from 1 for 6
),
52, 1
),
53, 1
),
'hex'
)::uuid;
$$;
Alternatively use a compiled extension such as pg_uuidv7 or generate the value in the application.
C# Example (client-side UUIDv7)
using System;
// Use a library that implements RFC 9562 UUIDv7, e.g. UUIDNext or similar
public static class IdFactory
{
public static Guid NewUuidV7() => Uuid.NewDatabaseFriendly(Database.PostgreSql);
// or any standards-compliant UUIDv7 generator
}
// Insert with client-generated key
await connection.ExecuteAsync(
@"INSERT INTO orders_uuid (id, customer_id, amount)
VALUES (@id, @customerId, @amount)",
new { id = IdFactory.NewUuidV7(), customerId, amount });
JavaScript / Node.js
import { v7 as uuidv7 } from 'uuid'; // modern uuid package
const id = uuidv7(); // string form
// or pass as binary if using a driver that accepts Buffer
Application Pattern (Hybrid)
// Generate public UUID client-side; let PostgreSQL assign the clustering-friendly key
var publicId = IdFactory.NewUuidV7();
var newId = await connection.QuerySingleAsync<long>(
@"INSERT INTO orders_hybrid (public_id, customer_id, amount)
VALUES (@publicId, @customerId, @amount)
RETURNING id",
new { publicId, customerId, amount });
Alternatives Comparison
| Approach | Insert Perf | Storage | Global Uniqueness | Client Generation | Predictability | Best For |
|---|---|---|---|---|---|---|
BIGINT GENERATED ... AS IDENTITY |
Best | Best | No | No | High | Single-database OLTP |
| Native / extension UUIDv7 | Near-best | Good | Yes | Yes | Low-Medium | Distributed writers, offline clients |
| Hybrid (IDENTITY + UUID column) | Best | Good | Yes (UUID) | Yes | High (ID) | Most production systems |
| UUIDv4 | Poor at scale | Worst | Yes | Yes | None | Avoid as PK on large tables |
| ULID / KSUID / Snowflake-style | Excellent | Good | Yes | Yes | Low | Strong alternatives to UUIDv7 |
| Application-managed sequences + shard | Excellent | Best | Coordinated | Partial | High | Massive multi-shard deployments |
Decision Guidelines
Prefer BIGINT GENERATED ALWAYS AS IDENTITY when:
- The database is the single source of truth for ID generation.
- Maximum insert throughput, minimal storage, and lowest cache pressure matter.
- IDs are internal (never exposed to clients or URLs).
Prefer UUIDv7 (or hybrid) when:
- Multiple independent writers / microservices / offline clients must generate IDs.
- You need globally unique identifiers that can be merged across databases without collision.
- Public-facing identifiers must be opaque and non-guessable.
Avoid UUIDv4 as a primary key on any table expected to exceed a few million rows or to sustain high write rates. The index fragmentation, WAL amplification, and cache inefficiency are real and measurable.
Hybrid pattern is frequently the pragmatic winner: keep a sequential 8-byte primary key for clustering and joins, and expose a UUIDv7 (or ULID) as a unique secondary column for external use. Foreign keys stay narrow, indexes stay dense, and the application still obtains the architectural benefits of client-generated identifiers.
Practical Recommendations
- Prefer
GENERATED ALWAYS AS IDENTITYover the legacyBIGSERIAL/SERIALsyntax. - Never store UUIDs as
textorvarchar; always use the nativeuuidtype. - On PostgreSQL 18+ use the built-in
uuidv7(). On older versions generate client-side or install a lightweight extension / pure-SQL function. - Monitor index bloat (
pgstattuple,pg_stat_user_indexes) and WAL volume when experimenting with random keys. - After bulk-loading random UUIDs, consider
REINDEX(or concurrent reindex) if fragmentation becomes measurable. - Keep foreign-key columns the same type and width as the referenced primary key; wider keys cascade through the entire schema.
Conclusion
In PostgreSQL the performance gap between sequential BIGINT keys and random UUIDv4 keys is real but less catastrophic than in engines that cluster on the primary key. The dominant costs are larger indexes, more page splits, poorer buffer-cache hit rates, and increased WAL traffic.
UUIDv7 largely closes the insert and locality gap while preserving global uniqueness and client-side generation. For the majority of systems the cleanest design is still a sequential BIGINT primary key (for internal efficiency) combined with a UUIDv7 unique column when a public, distributed identifier is required.
Measure on your own hardware and data shape—the relative costs change with row width, concurrency, and whether the working set fits in shared_buffers. With that data in hand, the choice between UUID and BIGSERIAL becomes straightforward rather than ideological.
Tools and Versions
Need some UUIDs for testing? Try our tools for generating
UUIDv4
UUIDv7
ULID
NanoID
and
decoding
validating.
For a detailed comparison of UUID versions, visit our Complete Guide to UUID Versions.