Every application needs unique identifiers. Whether you're identifying database rows, tracking distributed events, or generating public-facing resource IDs, the format you choose has real consequences for performance, readability, and correctness. The two most common options are UUID and ULID — and they differ in more ways than most developers realise.
In this guide we'll cover the full anatomy of both formats, walk through code examples in JavaScript and Python, dig into database B-tree fragmentation, and give you a clear framework for deciding which to reach for in any given situation.
What Is a UUID?
UUID stands for Universally Unique Identifier. Defined in RFC 4122 (and later updated in RFC 9562), a UUID is a 128-bit value typically rendered as 32 hexadecimal characters split into five hyphen-separated groups:
xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx
└──────┘ └──┘ └──┘ └──┘ └──────────┘
8 hex 4 4 4 12 hex
The M nibble encodes the version (1–8) and N encodes the variant. The total human-readable length is always 36 characters including hyphens, or 32 without.
UUID v1 — Timestamp + MAC Address
Version 1 encodes a 60-bit timestamp (100-nanosecond intervals since 15 October 1582), a clock sequence, and the generating node's MAC address. It is time-ordered, but the timestamp bits are scrambled across the 128-bit layout rather than placed at the front, so lexicographic sort order does not match creation order. It also leaks the host MAC address, which caused privacy concerns and led to its decline.
UUID v4 — Pure Random
Version 4 fills 122 bits with cryptographically random data and sets the version and variant bits. It is by far the most widely used UUID version today. There is zero risk of MAC address leakage, and collision probability is astronomically low (roughly 1 in 5.3 × 1036 for each new ID). The downside is that it is entirely unordered, which creates indexing problems we'll return to shortly.
UUID v5 — Namespace + SHA-1 Hash
Version 5 is deterministic: given the same namespace UUID and name string, it always produces the same output UUID. This makes it suitable for content-addressable identifiers — for example, generating a stable ID for a URL or an email address. Version 3 is the same concept but uses MD5; v5 with SHA-1 is preferred.
UUID v7 — Time-Ordered Random (The Modern Standard)
RFC 9562 introduced UUID v7 in 2024. It places a 48-bit Unix millisecond timestamp in the most significant bits, followed by random data. Because the timestamp is at the front, v7 UUIDs are monotonically increasing within a millisecond window and sort correctly in both binary and lexicographic order. UUID v7 is the recommended default for new systems that need a standard UUID format with good database performance.
// UUID v7 example
019640ab-c2f1-7b3e-9a4d-1e8f3c2d5b6a
└──────────────┘
48-bit ms timestamp at the front What Is a ULID?
ULID stands for Universally Unique Lexicographically Sortable Identifier. It was designed specifically to fix UUID's sortability and readability problems while maintaining 128-bit uniqueness. A ULID looks like this:
01ARZ3NDEKTSV4RRFFQ69G5FAV
That's 26 characters of Crockford Base32 — the alphabet 0123456789ABCDEFGHJKMNPQRSTVWXYZ, which deliberately excludes visually ambiguous characters like I, L, O, and U.
ULID Structure
01ARZ3NDEK TSSDFRFFQ69G5FAV
└────────┘ └──────────────┘
10 chars 16 chars
48-bit Unix 80-bit random
timestamp component
(ms precision) The timestamp occupies the first 10 characters (48 bits), encoding milliseconds since the Unix epoch. This means ULIDs generated in the same millisecond share the same prefix and sort together as a group. The remaining 16 characters (80 bits) are random, giving a theoretical ceiling of 280 unique IDs per millisecond — far more than any real-world system will ever need.
Because the most significant bits are always the timestamp, ULIDs sort lexicographically in creation order without any special database configuration.
Generating UUIDs and ULIDs in Code
JavaScript
// UUID v4 — built into modern runtimes
const uuidV4 = crypto.randomUUID();
// "f47ac10b-58cc-4372-a567-0e02b2c3d479"
// UUID v7 — use the 'uuid' npm package (v10+)
import { v7 as uuidv7 } from 'uuid';
const uuidV7 = uuidv7();
// "019640ab-c2f1-7000-8000-1e8f3c2d5b6a"
// ULID — use the 'ulid' npm package
import { ulid } from 'ulid';
const id = ulid();
// "01ARZ3NDEKTSV4RRFFQ69G5FAV"
// ULID with a specific timestamp (deterministic prefix)
const idAt = ulid(1711497600000);
// Extract timestamp from a ULID
import { decodeTime } from 'ulid';
const ts = decodeTime('01ARZ3NDEKTSV4RRFFQ69G5FAV');
// returns Unix ms timestamp as a number Python
import uuid
# UUID v4
uid_v4 = uuid.uuid4()
print(uid_v4)
# f47ac10b-58cc-4372-a567-0e02b2c3d479
# UUID v5 (namespace + name → deterministic)
uid_v5 = uuid.uuid5(uuid.NAMESPACE_URL, "https://example.com")
print(uid_v5)
# c74a196f-f19d-5a77-9bdf-c4d869f0e4cf
# UUID v7 — use the 'uuid6' package (pip install uuid6)
import uuid6
uid_v7 = uuid6.uuid7()
print(uid_v7)
# ULID — use the 'python-ulid' package (pip install python-ulid)
from ulid import ULID
id = ULID()
print(str(id))
# 01ARZ3NDEKTSV4RRFFQ69G5FAV
# Extract timestamp from ULID
print(id.timestamp) # float Unix seconds
print(id.milliseconds) # int Unix ms
print(id.datetime) # datetime object (UTC) Side-by-Side Comparison
Here is a structured breakdown of the key properties of each format:
Property UUID v4 UUID v7 ULID
─────────────────────────────────────────────────────────────────────
Length (chars) 36 (with hyphens) 36 (with hyphens) 26
Encoding Hex + hyphens Hex + hyphens Crockford Base32
Bit width 128 128 128
Sortable No Yes (ms precision) Yes (ms precision)
Timestamp embedded No Yes (48-bit ms) Yes (48-bit ms)
Timestamp extraction Impossible Yes Yes
Randomness bits 122 74–80 80
Case sensitive No No No (by spec)
URL safe Yes (with hyphens) Yes Yes
Human readable Low Low Medium
RFC standard RFC 9562 RFC 9562 Community spec
Widely supported Very high Growing Growing Database Indexing: The Core Performance Difference
This is where the UUID v4 vs ULID debate becomes most consequential in production systems. Understanding it requires a basic understanding of how database indexes work.
How B-Tree Indexes Work
Most relational databases (PostgreSQL, MySQL, SQLite) store indexes as B-trees — balanced tree structures where each leaf page holds a range of key values in sorted order. When you insert a new row, the database must place the new key into the correct leaf page. If that page is full, it splits the page, which can cascade into rebalancing the tree.
UUID v4 Fragmentation
Because UUID v4 values are purely random, each new insertion lands at a statistically random position in the B-tree. On a large table this means:
- Page splits happen constantly — every new random key may land in an already-full middle page, forcing a split.
- Low page fill factor — after many splits, pages are often only 50% full, doubling the storage footprint of the index.
- Cache thrashing — recent writes scatter across the entire key space, so the pages needed for new inserts are rarely in the buffer cache. This causes excessive disk I/O.
- Write amplification — on NVMe and SSD storage, random writes are less efficient than sequential writes even at the hardware level.
On small tables (under a few hundred thousand rows) this is invisible. On tables with tens of millions of rows, random UUID primary keys can cause insert throughput to drop by 50–80% compared to sequential keys, and index size can balloon to 2–3× what an ordered key would occupy.
ULID and UUID v7 Sequential Inserts
ULID and UUID v7 both place the timestamp in the high-order bits. New IDs are always greater than any existing ID generated more than 1 ms ago. This means inserts always append to the rightmost leaf page of the B-tree — the behaviour is identical to a traditional auto-increment integer primary key. Page splits are rare, pages stay full, and the working set (recently-written pages) remains small and cache-friendly.
PostgreSQL benchmarks from the Cybertec blog and similar independent tests consistently show that time-ordered UUIDs (v7) or ULIDs achieve insert throughput within 5–10% of BIGSERIAL, while random UUID v4 can be 3–5× slower on tables with millions of rows and indexes.
A Practical PostgreSQL Example
-- UUID v4 primary key — index fragmentation over time
CREATE TABLE events_uuid (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
payload JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ULID stored as text — lexicographic sort = time sort
CREATE TABLE events_ulid (
id TEXT PRIMARY KEY, -- insert ULID values from application
payload JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- UUID v7 — best of both worlds in PostgreSQL 17+
CREATE TABLE events_uuid7 (
id UUID PRIMARY KEY DEFAULT uuidv7(), -- requires pg_uuidv7 extension
payload JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
); Sortability in Practice
Sortability matters beyond just index performance. When your IDs are time-ordered you get several useful properties for free:
- Pagination without a separate
created_atcolumn — cursor-based pagination can use the ID itself as a cursor. - Approximate creation time from ID — useful for debugging, auditing, and data migration without needing an extra timestamp column.
- Natural ordering in log files and API responses — IDs sort visually in the order records were created.
- Distributed event ordering — in a microservices architecture, events from different services can be merged and sorted by ID without clock synchronisation.
Need to work with Unix timestamps in your project? The DevToolkit Timestamp Converter lets you convert between Unix ms, ISO 8601, and human-readable formats instantly — handy when you're extracting the timestamp component from a ULID or UUID v7.
When to Choose UUID
Use UUID v4 When:
- You need maximum interoperability — UUID v4 is supported natively by every database, ORM, and programming language without extra dependencies.
- Your table will stay small (under 1 million rows) and insert performance is not a bottleneck.
- You are integrating with an existing system that expects standard UUID format.
- You specifically need IDs with no embedded timestamp (privacy requirement — the creation time must not be inferrable from the ID).
- You need deterministic IDs from content (use UUID v5).
Use UUID v7 When:
- You need standard RFC-compliant UUID format (compatible with existing UUID columns and tooling).
- You want time-ordered IDs with good database performance but don't want to introduce a new format.
- You're on PostgreSQL and can use the
pg_uuidv7extension for database-level generation. - You're migrating an existing UUID v4 system and want a drop-in improvement.
When to Choose ULID
- You want time-ordered IDs with a shorter, more readable string representation (26 chars vs 36).
- You need to embed IDs in URLs, filenames, or log lines where shorter is better.
- You are building a new system from scratch with no legacy UUID compatibility requirement.
- You want easy timestamp extraction in application code without a database extension.
- Your team values the Crockford Base32 alphabet's human-readability (no ambiguous characters, case-insensitive).
- You are working in a language or framework where good ULID libraries exist (JavaScript, Python, Go, Rust all have mature ULID packages).
UUID v7 vs ULID: Which Modern Format Wins?
If you're starting a new project today and want time-ordered IDs, the choice is between UUID v7 and ULID. Both solve the core database performance problem. The deciding factors are:
Factor UUID v7 ULID
─────────────────────────────────────────────────────
RFC standard Yes (RFC 9562) No (community)
Native DB support Growing Manual (store as TEXT)
String length 36 chars 26 chars
Existing UUID tooling Drop-in compatible Incompatible
Library maturity Growing Mature since 2016
Timestamp precision Millisecond Millisecond
Monotonicity option Optional (sub-ms) Yes (monotonic mode) For most teams: if you're on PostgreSQL or MySQL and want to stay in the UUID ecosystem, use UUID v7. If you value shorter strings, better readability, or are building a greenfield system without legacy UUID constraints, ULID is an excellent choice.
Monotonic Mode: Solving the Same-Millisecond Problem
Both UUID v7 and ULID face a subtle edge case: if you generate many IDs within the same millisecond, the random component means they won't necessarily sort in generation order within that millisecond window.
ULID's specification includes a monotonic mode that increments the random component by 1 for each ID generated within the same millisecond, guaranteeing strict ordering even at very high generation rates:
import { monotonicFactory } from 'ulid';
const ulid = monotonicFactory();
// These are generated in the same millisecond
// but are guaranteed to sort in generation order
const id1 = ulid();
const id2 = ulid();
const id3 = ulid();
// id1 < id2 < id3 always holds UUID v7 has a similar optional sub-millisecond precision mechanism where additional random bits can be used as a monotonic counter, though implementation varies by library.
Storage Considerations
At the binary level, both UUID and ULID are 128 bits (16 bytes). The difference is purely in the string representation:
- UUID stored as
UUIDtype (PostgreSQL, MySQL): 16 bytes — optimal. - UUID stored as
VARCHAR(36): 36 bytes — 2.25× overhead. - ULID stored as
TEXT: 26 bytes — better than VARCHAR UUID but not as good as native binary. - ULID stored as
UUID: Possible — ULID can be stored in a native UUID column since it's 128 bits. Some teams do this and convert in the application layer.
If you're using PostgreSQL, always use the native UUID type for UUID values, never VARCHAR. The 16-byte binary storage is 2× more compact and indexes are smaller and faster.
Working with UUIDs in Developer Tools
When debugging or testing, you'll frequently need to generate UUIDs on demand. The DevToolkit UUID Generator lets you create UUID v1, v4, and v7 values instantly in your browser — no installation, no dependencies, just copy and go. It also supports bulk generation when you need a batch of test IDs.
For understanding the timestamp embedded in a UUID v7 or ULID, pair it with the Timestamp Converter to decode the millisecond epoch into a human-readable date and time.
If you're working on data pipelines where IDs travel through encoding layers, it's worth understanding how your identifiers behave when Base64-encoded or embedded in JSON payloads — see Base64 Encoding Explained and Guide to JSON Formatting and Validation for the full picture.
Quick Decision Framework
- Need maximum compatibility with existing systems? Use UUID v4 if the table is small, UUID v7 if performance matters.
- Building something new with no legacy constraints? ULID or UUID v7 — both are excellent.
- Table will have millions of rows with frequent inserts? Avoid UUID v4. Use UUID v7 or ULID.
- Need deterministic IDs from content? UUID v5.
- Need shorter strings in URLs or filenames? ULID wins with 26 chars vs 36.
- Need strict privacy (no inferrable creation time)? UUID v4 is the only option.
Summary
UUID v4 is a safe, universally supported default, but its random nature makes it a poor choice for primary keys on large tables. ULID and UUID v7 both solve this problem elegantly by embedding a millisecond timestamp in the high-order bits, turning inserts into efficient sequential appends that keep B-tree indexes healthy.
If you're working in an existing UUID ecosystem, migrate toward UUID v7. If you're greenfielding and value shorter, more readable identifiers, ULID is a mature and excellent choice. Either way, moving away from random UUID v4 as a primary key on high-write tables is one of the highest-leverage, lowest-effort database performance improvements you can make.
Ready to start generating IDs? Try the DevToolkit UUID Generator — generate UUID v1, v4, and v7 values instantly, in bulk, directly in your browser with zero setup required.