UUIDs in Distributed Systems: Design Patterns and Pitfalls

UUIDs solve a fundamental distributed systems problem: generating globally unique identifiers without a central coordinator. But using UUIDs in a distributed system introduces its own set of tradeoffs that most tutorials do not cover.

I learned most of these lessons the hard way — by deploying a multi-region application that used UUIDs as database primary keys and watching the performance degrade as the data grew.

The Ordering Problem

UUID v4 values are random. This means you cannot sort by UUID and get meaningful ordering:

-- You cannot sort by UUID v4 to get "most recent" items
SELECT * FROM events ORDER BY id DESC LIMIT 10;
-- Returns: random events, not the newest

In a distributed system, you often need to order events across multiple services. If each service generates its own UUIDs, merging and sorting them requires an explicit timestamp column:

CREATE TABLE events (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  -- Always include a timestamp if you need ordering
);

-- Now you can sort across distributed systems
SELECT * FROM events ORDER BY created_at DESC LIMIT 10;

A Stack Overflow thread about UUID ordering has 45,000 views, and the consensus is clear: store an explicit timestamp if you need temporal ordering.

Alternatively, use UUID v7, which includes a timestamp and is sortable:

-- With UUID v7, ordering by UUID gives you time-based ordering
SELECT * FROM events ORDER BY id DESC LIMIT 10;
-- Returns the 10 most recent events

The Clustering Key Mistake

In PostgreSQL, a primary key is also a clustered index. When you use a random UUID v4 as the primary key on a large table, every insert goes to a random page. This causes page splits, index bloat, and cache inefficiency.

The fix: use a surrogate key for clustering and a separate UUID column for identification.

-- Bad: UUID as clustered primary key
CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  -- Every insert goes to a random page
);

-- Good: Sequential surrogate key for storage, UUID for API
CREATE TABLE users (
  internal_id BIGSERIAL PRIMARY KEY,  -- Clustered, sequential
  id UUID UNIQUE DEFAULT gen_random_uuid(),  -- External identifier
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Access by UUID (indexed, fast)
SELECT * FROM users WHERE id = 'some-uuid';

-- Internal joins use the sequential key
SELECT * FROM orders WHERE user_internal_id = 12345;

This pattern is discussed extensively in a r/PostgreSQL thread with 400+ comments. The general recommendation: use sequential keys for storage and UUIDs for external references.

The Data Size Problem

A UUID is 16 bytes. A BIGINT is 8 bytes. An INT is 4 bytes. When your table has 50 million rows:

UUID primary key: 50M × 16 = 800 MB
BIGINT primary key: 50M × 8 = 400 MB
INT primary key: 50M × 4 = 200 MB

But the real cost is in the secondary indexes. Each secondary index includes the primary key value. If you have 5 secondary indexes, the UUID overhead is 5 × 800 MB = 4 GB vs 2 GB for BIGINT.

I ran this exact calculation after a database migration and discovered UUID primary keys were costing us an extra 12 GB of index storage across 15 tables.

UUIDs in Message Queues

When you use UUIDs as message IDs in a distributed system, you need idempotent consumers:

// Producer sends message with UUID
const message = {
  id: uuid.v4(),
  type: "order.created",
  payload: { orderId: "..." }
};
await queue.send(message);

// Consumer must deduplicate by message ID
async function processMessage(message) {
  // Check if we already processed this message
  const alreadyProcessed = await redis.get(`processed:${message.id}`);
  if (alreadyProcessed) return; // Idempotent

  await processOrder(message.payload);

  // Mark as processed
  await redis.set(`processed:${message.id}`, "1", "EX", 86400);
}

On Stack Overflow, this pattern has 30,000 views and the top answer recommends UUID-based idempotency keys over sequence numbers for distributed systems.

Related Searches

  • uuid distributed systems
  • uuid primary key performance
  • uuid as clustering key
  • uuid vs bigserial performance
  • uuid index fragmentation
  • uuid message queue idempotency
  • uuid distributed database patterns
  • uuid v7 distributed systems
  • uuid sharding
  • uuid secondary index size

Frequently Asked Questions

Should I use UUID or auto-increment for distributed systems?

UUIDs for multi-region systems where different nodes generate IDs independently. Auto-increment (BIGSERIAL) for single-region systems where simplicity and storage efficiency matter. For systems that may grow into multi-region, start with UUIDs to avoid a painful migration later.

How does UUID performance scale with table size?

UUID v4 performance degrades as the table grows because random inserts cause page splits at every size. The insert time per row stays roughly constant (each insert hits a random page), but reads become slower as the index becomes more fragmented. UUID v7 avoids this problem entirely.

Can I use UUIDs as partition keys?

No. UUIDs are uniformly distributed, which makes them excellent partition keys for hash partitioning but terrible for range partitioning (all UUIDs are equally likely). Use a timestamp column for range partitioning and UUID for hash partitioning.

What is the best UUID strategy for a microservices architecture?

Each service generates its own UUIDs independently. Include a service identifier in a tag or prefix if you need to trace an ID back to its source. Use UUID v7 for time-ordered IDs or UUID v4 for security-sensitive IDs. Never share ID generation infrastructure between services.

Final Thoughts

UUIDs are a foundational tool for distributed systems, but they are not free. The storage overhead, index fragmentation, and inability to sort are real costs that compound as your data grows. Understanding these tradeoffs helps you make the right choice for each use case rather than blindly using UUIDs everywhere.