ReviseAlgo Logo

Keys & Constraints

Surrogate, Natural, and Composite Keys

Identifying best column configurations for primary index keys.

Last Updated: June 15, 2026 15 min read

1. Introduction

Choosing the right primary key strategy is one of the most impactful schema design decisions. Surrogate keys are auto-generated IDs with no business meaning (e.g., SERIAL, UUID). Natural keys are real-world identifiers (e.g., email, SSN, ISBN). Composite keys combine multiple columns to form a unique identifier. Each approach has trade-offs in performance, simplicity, and maintainability.

2. Why It Matters

  • Join performance: Integer surrogate keys are 4 bytes; UUID is 16 bytes; composite keys can be even larger. Smaller keys = smaller indexes = faster joins.
  • Immutability: Primary keys should never change. Natural keys (email, phone) can change; surrogate keys are stable.
  • Distributed systems: Auto-increment keys don't work across multiple databases. UUIDs or snowflake IDs enable distributed key generation.
  • ORM compatibility: Most ORMs assume single-column integer primary keys. Composite or natural keys require special configuration.

3. Real-World Analogy

Think of employee identification. A surrogate key is an auto-assigned employee number (E-1234) — meaningless outside the company, never changes. A natural key would be the employee's email — meaningful but can change when they marry or rebrand. A composite key would be (department, hire_date, sequence) — unique only in combination, and cumbersome to reference.

4. How It Works

-- SURROGATE KEY (recommended default)
CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,  -- 8-byte auto-increment
    email TEXT UNIQUE NOT NULL,
    name TEXT NOT NULL
);

-- UUID surrogate key (for distributed systems) CREATE TABLE events ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), type TEXT NOT NULL, payload JSONB NOT NULL, created_at TIMESTAMP DEFAULT NOW() );

-- NATURAL KEY CREATE TABLE countries ( iso_code CHAR(2) PRIMARY KEY, -- 'US', 'UK', 'JP' name TEXT NOT NULL );

-- COMPOSITE KEY CREATE TABLE enrollment ( student_id INT REFERENCES students(id), course_id INT REFERENCES courses(id), semester TEXT, grade TEXT, PRIMARY KEY (student_id, course_id, semester) );

-- Hybrid: surrogate PK + natural unique key CREATE TABLE products ( id SERIAL PRIMARY KEY, -- surrogate PK for joins sku TEXT UNIQUE NOT NULL, -- natural key for business logic name TEXT NOT NULL );

5. Internal Architecture

Key Type Performance Comparison:
┌──────────────┬────────┬────────────┬────────────┬──────────────┐
│ Key Type     │ Size   │ Index Size │ Join Speed │ Distribution │
├──────────────┼────────┼────────────┼────────────┼──────────────┤
│ INT (4B)     │ 4 bytes│ Smallest   │ Fastest    │ Single DB    │
│ BIGINT (8B)  │ 8 bytes│ Small      │ Very Fast  │ Single DB    │
│ UUID (16B)   │ 16B    │ 2-3x INT   │ Fast       │ Multi-DB     │
│ TEXT         │ Var    │ Largest    │ Slowest    │ N/A          │
│ Composite    │ Sum    │ Largest    │ Slowest    │ N/A          │
└──────────────┴────────┴────────────┴────────────┴──────────────┘

SERIAL vs BIGSERIAL: SERIAL: 1 to 2,147,483,647 (2.1B max) BIGSERIAL: 1 to 9.2 quintillion (practically unlimited) → Use BIGSERIAL for high-volume tables (events, logs)

UUID considerations: - Random UUIDs cause index fragmentation (random inserts) - UUIDv7 (time-ordered) maintains B-tree locality - 16 bytes vs 4 bytes = 4x larger indexes

6. Visual Explanation

7. Practical Example

-- Best practice: Surrogate PK + natural unique constraints
CREATE TABLE customers (
    id BIGSERIAL PRIMARY KEY,
    email TEXT UNIQUE NOT NULL,       -- natural business key
    phone TEXT UNIQUE,                -- secondary natural key
    name TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);

-- All joins use the small surrogate key: SELECT o.id, c.name, o.total FROM orders o JOIN customers c ON c.id = o.customer_id -- fast INT join WHERE c.email = 'alice@example.com'; -- natural key for lookups

-- For distributed systems: ULID (time-ordered, sortable) CREATE TABLE audit_log ( id TEXT PRIMARY KEY, -- ULID: 01HXYZ... (26 chars, time-ordered) action TEXT NOT NULL, entity_id BIGINT NOT NULL, performed_by BIGINT REFERENCES users(id), created_at TIMESTAMP DEFAULT NOW() );

8. Common Mistakes

Using natural keys as primary keys

Email, SSN, and phone numbers can change. When a PK changes, every FK referencing it must be updated (CASCADE) or the update is blocked. Use a surrogate PK and keep the natural key as a separate UNIQUE column.

Using random UUIDs on high-volume tables

Random UUIDs cause random inserts into the B-tree index, leading to page splits and fragmentation. For high-volume tables, use UUIDv7 (time-ordered) or ULID to maintain insert locality.

Interview Insight

"When would you use a composite primary key?" — Junction tables (many-to-many), time-series data (device_id + timestamp), and partitioned tables. For most entity tables, prefer a single surrogate key.

9. Quick Quiz

Q1: Why are surrogate keys preferred for distributed systems?

Answer: Auto-increment keys require a central sequence (single DB). UUIDs and snowflake IDs can be generated independently on any node without coordination, enabling horizontal scaling across multiple database instances.

10. Scenario-Based Challenge

Challenge: Multi-Region E-Commerce Key Strategy

Your e-commerce platform runs in 3 regions (US, EU, APAC) with separate databases. Design a key strategy for:

  1. Orders table: IDs must be globally unique across regions.
  2. Products table: SKU must be consistent across regions.
  3. Users table: email uniqueness must be enforced globally.
  4. Order items: decide between surrogate and composite keys, justifying your choice.

11. Debugging Exercise

This table is running out of IDs. Why?

CREATE TABLE events (
    id SERIAL PRIMARY KEY,
    event_type TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);
-- Table has 2.1 billion rows and INSERTs are failing

Issue:

  1. SERIAL max value: SERIAL uses INT (4 bytes), max value is 2,147,483,647. At 2.1B rows, you're at the limit. Fix: migrate to BIGSERIAL (8 bytes) or use UUIDs. Prevention: always use BIGSERIAL for high-volume tables.

12. Interview Questions

Q1: What are the pros and cons of UUID vs integer primary keys?

A: Integers: smaller (4/8 bytes), faster joins, sequential inserts, but require central generation. UUIDs: globally unique (no coordination), secure (not guessable), but 4x larger indexes, random inserts cause fragmentation. Use integers for single-DB OLTP, UUIDs for distributed systems.

Q2: How do you generate unique IDs in a sharded database?

A: Options: (1) UUID v4 (random) or UUID v7 (time-ordered), (2) Snowflake IDs (timestamp + machine ID + sequence), (3) Ticket server (central ID generator), (4) Database sequences with shard-specific offsets.

13. Production Considerations

  • Default to BIGSERIAL: Use BIGSERIAL (8 bytes) instead of SERIAL (4 bytes) for all new tables. The extra 4 bytes per row prevents the 2.1B limit issue and costs negligible storage.
  • UUID v7 for distributed: If using UUIDs, prefer UUID v7 (time-ordered) over v4 (random) to maintain B-tree insert locality and reduce index fragmentation.
  • Sequence tuning: For high-insert tables, increase sequence cache: ALTER SEQUENCE orders_id_seq CACHE 100. This pre-allocates IDs in memory, reducing sequence contention.
  • Hybrid approach: Use surrogate PK for joins and internal references, plus a UNIQUE natural key (email, SKU) for business logic and external APIs.
  • Composite key indexing: Column order matters in composite keys. Put the most selective column first for the best index performance.