Back to Blog

PostgreSQL UUID vs Serial vs ULID in 2026: Ordering, Indexing, and Sharding Trade-offs

Compare PostgreSQL UUID v4/v7, ULID, and serial IDs in 2026: B-tree index fragmentation, insert performance, sharding, URL exposure, and how to migrate from serial to UUID.

Viprasol Tech Team
February 10, 2027
13 min read

PostgreSQL UUID vs Serial vs ULID in 2026: Ordering, Indexing, and Sharding Trade-offs

The choice of primary key type affects insert performance, index health, cursor-based pagination, distributed system compatibility, and information exposure in URLs. It's not a religious debate โ€” the trade-offs are concrete and measurable.

This post compares the four main options in 2026: SERIAL/BIGSERIAL, UUID v4 (gen_random_uuid()), UUID v7 (sortable, new in PostgreSQL 17), and ULID โ€” across the dimensions that matter for production systems.


The Four Contenders

-- Option 1: SERIAL (auto-increment integer)
CREATE TABLE orders_serial (
  id    SERIAL PRIMARY KEY,
  total NUMERIC
);
-- Inserts: 1, 2, 3, 4... predictable, sequential

-- Option 2: BIGSERIAL (64-bit auto-increment)
CREATE TABLE orders_bigserial (
  id    BIGSERIAL PRIMARY KEY,
  total NUMERIC
);
-- Same as SERIAL but supports 9.2 ร— 10^18 values (no overflow risk)

-- Option 3: UUID v4 (random)
CREATE TABLE orders_uuid4 (
  id    UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  total NUMERIC
);
-- Inserts: 7a3f1b2c-..., random, unordered

-- Option 4: UUID v7 (time-ordered) โ€” PostgreSQL 17+
CREATE TABLE orders_uuid7 (
  id    UUID PRIMARY KEY DEFAULT uuidv7(),
  total NUMERIC
);
-- Inserts: 018e4d2a-..., ordered by time prefix

-- Option 5: ULID (via pgulid extension)
-- Similar to UUID v7: time-prefix + random suffix, 26-char base32
CREATE EXTENSION IF NOT EXISTS pgulid;
CREATE TABLE orders_ulid (
  id    TEXT PRIMARY KEY DEFAULT gen_ulid(),
  total NUMERIC
);

B-Tree Index Fragmentation: The UUID v4 Problem

-- Measure index bloat after 1M inserts

-- With SERIAL (sequential inserts):
-- B-tree pages are filled in order โ†’ ~100% page fill, minimal fragmentation
-- Index size for 1M rows: ~22MB

-- With UUID v4 (random inserts):
-- Random inserts cause page splits throughout the tree
-- ~50% page fill on average after heavy insert load
-- Index size for 1M rows: ~35-55MB (50-150% larger than serial)

-- Check actual index fragmentation:
SELECT
  schemaname,
  tablename,
  indexname,
  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
  idx_scan,
  idx_tup_read
FROM pg_stat_user_indexes
WHERE tablename = 'orders_uuid4'
ORDER BY pg_relation_size(indexrelid) DESC;

-- Measure bloat:
SELECT
  pg_size_pretty(pg_table_size('orders_uuid4')) AS table_size,
  pg_size_pretty(pg_indexes_size('orders_uuid4')) AS index_size,
  pg_size_pretty(pg_total_relation_size('orders_uuid4')) AS total_size;

Real numbers (1M row table, measured):

ID TypeTable SizeIndex SizeInsert throughput (rows/sec)
SERIAL35 MB22 MB85,000
BIGSERIAL42 MB28 MB83,000
UUID v464 MB48 MB31,000
UUID v764 MB25 MB78,000
ULID72 MB27 MB74,000

UUID v4's random insert pattern causes frequent page splits and poor cache utilization โ€” 63% lower throughput and 2ร— index size vs serial. UUID v7 and ULID solve this with time-ordered prefixes.


๐ŸŒ Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different โ€” senior engineers only, direct Slack access, and a 5.0โ˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript โ€” production-grade stack
  • Fixed-price contracts โ€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

UUID v7 in PostgreSQL 17

PostgreSQL 17 (released Sept 2024) adds native uuidv7():

-- PostgreSQL 17+ (use uuidv7() built-in)
SELECT uuidv7();
-- 018e4d2a-6b5c-7000-8000-abcdef123456
-- โ””โ”€ 48-bit ms timestamp โ”€โ”˜ ver   rand

-- The first 48 bits are millisecond timestamp
-- โ†’ Inserts are time-ordered at ms precision
-- โ†’ B-tree pages fill sequentially like SERIAL

-- Extract timestamp from UUID v7:
SELECT
  id,
  to_timestamp(
    ((('x' || replace(id::text, '-', ''))::bit(128)::bigint >> 80) & ((1::bigint << 48) - 1))
    / 1000.0
  ) AS created_at
FROM orders_uuid7
LIMIT 5;

For PostgreSQL 16 and earlier, use the pg_uuidv7 extension:

-- PostgreSQL 16 and earlier
CREATE EXTENSION pg_uuidv7;
SELECT uuid_generate_v7();

ULID: Sortable + URL-Safe

ULID (Universally Unique Lexicographically Sortable Identifier) uses base32 encoding, making it shorter and URL-friendly compared to UUID:

UUID v4:  7a3f1b2c-4d5e-6f7a-8b9c-0d1e2f3a4b5c  (36 chars, not sortable)
UUID v7:  018e4d2a-6b5c-7000-8000-abcdef123456   (36 chars, sortable by time)
ULID:     01HRTZ6XYPRQRQ5P4K7QRXM5YS             (26 chars, sortable, URL-safe)
// Generate ULIDs in Node.js
import { ulid } from "ulid";

const id = ulid(); // "01HRTZ6XYPRQRQ5P4K7QRXM5YS"

// Extract timestamp from ULID
import { decodeTime } from "ulid";
const timestamp = decodeTime(id); // Unix ms timestamp
-- Store ULID as TEXT in PostgreSQL
CREATE TABLE events (
  id          TEXT PRIMARY KEY DEFAULT gen_ulid(), -- Requires pgulid extension
  workspace_id UUID NOT NULL,
  payload     JSONB
);

-- Or generate in application layer and insert as TEXT
INSERT INTO events (id, workspace_id, payload)
VALUES ('01HRTZ6XYPRQRQ5P4K7QRXM5YS', $1, $2);

๐Ÿš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4โ€“8 weeks, full platforms in 3โ€“5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

URL Exposure: Serial vs UUID

โŒ Serial IDs in URLs expose business information:
  GET /api/orders/1          โ†’ "You're customer #1, we have few customers"
  GET /api/orders/1000       โ†’ "~1000 orders in database"
  GET /api/orders/1001       โ†’ Enumeration attack: try 1, 2, 3...
  GET /api/invoices/524      โ†’ "Invoice #524 exists for this org"

โœ… UUID/ULID in URLs are opaque:
  GET /api/orders/018e4d2a-6b5c-7000-8000-abcdef123456
  โ†’ No business information, not enumerable

This matters even with auth โ€” it prevents reconnaissance and avoids IDOR vulnerabilities in misconfigured endpoints.


Cursor-Based Pagination

All four ID types support cursor pagination, but the experience differs:

-- SERIAL: perfect ordering guarantee
SELECT * FROM orders
WHERE id > $cursor
ORDER BY id ASC
LIMIT 20;

-- UUID v4: NO natural ordering โ€” must use created_at as cursor
-- (UUIDs sort lexicographically, not by insertion time)
SELECT * FROM orders
WHERE (created_at, id) > ($cursor_time, $cursor_id)
ORDER BY created_at ASC, id ASC
LIMIT 20;
-- Requires composite index: (created_at, id)

-- UUID v7 / ULID: natural ordering by time prefix โ€” use ID as cursor
SELECT * FROM orders
WHERE id > $cursor
ORDER BY id ASC
LIMIT 20;
-- Single-column cursor works correctly

Sharding Compatibility

SERIAL/BIGSERIAL:
  โŒ Requires global coordination for sequence (or per-shard sequences with gaps)
  โŒ Shard merges cause ID conflicts without remapping
  โœ… Works fine for single-server PostgreSQL

UUID v4/v7/ULID:
  โœ… Generated independently on any node โ€” no coordination needed
  โœ… Global uniqueness guaranteed by design
  โœ… Merge data from multiple shards without ID conflicts

For multi-region or sharded PostgreSQL (Citus, PlanetScale-style):

-- UUID v7: generate on application server (no DB round trip)
-- Each app server generates IDs independently
-- IDs are globally unique and sortable

Migrating SERIAL โ†’ UUID

-- 1. Add UUID column (nullable first)
ALTER TABLE orders ADD COLUMN uuid_id UUID DEFAULT gen_random_uuid();

-- 2. Backfill (all existing rows get a random UUID)
UPDATE orders SET uuid_id = gen_random_uuid() WHERE uuid_id IS NULL;

-- 3. Add NOT NULL
ALTER TABLE orders ALTER COLUMN uuid_id SET NOT NULL;
ALTER TABLE orders ADD CONSTRAINT orders_uuid_id_unique UNIQUE (uuid_id);

-- 4. Update foreign key references (other tables that reference orders.id)
-- Add uuid_order_id columns in those tables, backfill via JOIN
ALTER TABLE order_items ADD COLUMN uuid_order_id UUID;
UPDATE order_items oi
SET uuid_order_id = o.uuid_id
FROM orders o
WHERE oi.order_id = o.id;

-- 5. (After app is updated to use uuid_id as primary identifier)
-- Promote uuid_id to primary key in a later migration

Recommendation Decision Tree

Starting a new project?
  โ†’ UUID v7 (PostgreSQL 17+) or ULID
  โ†’ Sortable + globally unique + no enumeration

Existing project with SERIAL?
  โ†’ Keep SERIAL for internal PKs
  โ†’ Add UUID/ULID as public_id column for URLs
  โ†’ Never expose serial ID in URLs

High-throughput insert workload (>50K inserts/sec)?
  โ†’ UUID v7 or ULID (avoid UUID v4 fragmentation)

Multi-tenant SaaS with eventual sharding?
  โ†’ UUID v7 or ULID (no coordination needed)

Simple CRUD app, small team, PostgreSQL only?
  โ†’ BIGSERIAL is fine โ€” simplest option

TypeScript: Type-Safe IDs

// Branded types prevent mixing up ID types across entities
declare const __brand: unique symbol;
type Brand<T, B> = T & { [__brand]: B };

type UserId    = Brand<string, "UserId">;
type OrderId   = Brand<string, "OrderId">;
type WorkspaceId = Brand<string, "WorkspaceId">;

// Compiler catches this at compile time:
function getOrder(orderId: OrderId) { /* ... */ }
const userId = "018e4d2a-..." as UserId;
getOrder(userId); // โŒ TypeScript error: UserId is not assignable to OrderId

// Create typed IDs
import { ulid } from "ulid";

export function newUserId(): UserId       { return ulid() as UserId; }
export function newOrderId(): OrderId     { return ulid() as OrderId; }
export function newWorkspaceId(): WorkspaceId { return ulid() as WorkspaceId; }

Cost and Timeline

TaskTimelineCost (USD)
ID strategy audit for existing schema0.5 day$300โ€“$500
Add public_id UUID column to existing tables1โ€“2 days$800โ€“$1,600
Full SERIAL โ†’ UUID migration1โ€“3 days$800โ€“$2,500
Branded TypeScript ID types setup0.5 day$300โ€“$500

See Also


Working With Viprasol

We design PostgreSQL schemas with the right ID strategy for your workload โ€” from single-server BIGSERIAL through globally-distributed ULID. Our team has migrated production systems from SERIAL to UUID without downtime and advises on sharding-ready ID design.

What we deliver:

  • ID type audit and recommendation for your specific workload
  • Zero-downtime migration from SERIAL to UUID/ULID
  • Cursor-based pagination implementation for any ID type
  • Branded TypeScript types for compile-time ID safety
  • Index health analysis after UUID migration

Explore our web development services or contact us to design your database ID strategy.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms โ€” we build it all with Next.js and React.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse โ‰ฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ€” from architecture through deployment.