UUID vs ULID: Which Unique ID Should You Use?
21 February, 2026 Backend
UUID and ULID are both 128-bit identifiers used as primary keys and correlation IDs in distributed systems. They look different, behave differently under load, and each has trade-offs the other doesn't. This article covers the technical internals and gives you a clear decision framework.
What Is UUID?
UUID (Universally Unique Identifier) is a 128-bit identifier standardised in RFC 4122. Despite five versions existing, you will almost always encounter UUID v4 - randomly generated, no external dependencies, no central coordinator required.
f47ac10b-58cc-4372-a567-0e02b2c3d479
The format is always 8-4-4-4-12 hexadecimal digits. Bits 48-51 of the raw value encode the version (4), and bits 64-65 encode the variant (10 binary, RFC 4122 variant). The remaining 122 bits are random, giving a collision probability of roughly 1 in 2^61 for a billion IDs per second over 85 years - effectively zero for any real system.
Generate a UUID v4 directly in your browser.
The Other UUID Versions
- v1 - encodes a MAC address and timestamp. Deprecated in RFC 9562 because it leaks hardware information and is not random enough for security-sensitive use.
- v3 / v5 - deterministic, derived from a namespace + name via MD5 or SHA-1. Useful for generating a stable ID for a known entity (same input always produces the same UUID).
- v4 - 122 bits of randomness. The industry default for primary keys.
- v7 - time-ordered, introduced in RFC 9562. Covered in detail below.
What Is ULID?
ULID (Universally Unique Lexicographically Sortable Identifier) is a 128-bit ID defined in the ULID community spec. It splits its 128 bits into two parts:
- 48 bits - Unix timestamp in milliseconds (good until the year 10895)
- 80 bits - cryptographically random
Both parts are encoded together as 26 characters using Crockford's Base32, which deliberately excludes ambiguous characters (0, O, I, L):
01ARZ3NDEKTSV4RRFFQ69G5FAV
└─── 10 chars ───┘└─── 16 chars ────┘
timestamp (48 bits) random (80 bits)
The timestamp prefix makes ULIDs lexicographically sortable: two ULIDs can be compared as plain strings and the one generated later will always sort after the earlier one. Generate a ULID and notice that consecutive IDs share the same first 10 characters if generated within the same millisecond.
Same-Millisecond Monotonicity
If two ULIDs are generated within the same millisecond, the spec requires the random component to increment by 1 to preserve ordering. This guarantees strict sort order even at very high generation rates (thousands per millisecond). The 80-bit random space makes overflow within a single millisecond astronomically unlikely in practice.
UUID v7: The Modern Standard
UUID v7 was standardised in RFC 9562 (April 2024) and combines the time-ordering of ULID with the ubiquity of the UUID format:
018e2b3d-a1c2-7000-b0e5-4a9f1c8d7e2a
It uses a 48-bit Unix millisecond timestamp, 12 version/sequence bits, and 62 random bits. UUID v7 is byte-compatible with UUID v4 - the same 16-byte storage, same uuid column type in PostgreSQL or MySQL, same wire format in every framework. For teams already using UUID infrastructure, upgrading to v7 is often the lowest-friction path to time-ordered IDs.
Generate a UUID v7 to compare.
Side-by-Side Comparison
| Feature | UUID v4 | ULID | UUID v7 |
|---|---|---|---|
| String length | 36 chars | 26 chars | 36 chars |
| Encoding | Hex | Crockford Base32 | Hex |
| Sortable | No | Yes | Yes |
| Timestamp precision | None | 48-bit ms | 48-bit ms |
| Same-ms ordering | No | Guaranteed (spec) | Yes (seq bits) |
| Spec | RFC 4122 | Community | RFC 9562 |
| Native DB type | Yes | No | Yes |
| Exposes creation time | No | Yes | Yes |
| Binary storage | 16 bytes | 16 bytes | 16 bytes |
Database Performance: Why Random IDs Hurt
Index Fragmentation in B-Tree Indexes
Most relational databases store primary key indexes as B-trees. In MySQL/MariaDB InnoDB, the primary key is a clustered index - table rows are physically stored in primary key order on disk. This means every insert must land in the correct sorted position:
With UUID v4 (random):
- The page containing the random ID's position must be loaded into the buffer pool - this is a random read from disk.
- If that page is already full, a page split occurs: the page is divided in two, half the rows are moved, and a new page is allocated. Page splits are slow and leave pages half-full.
- After millions of inserts, the index is heavily fragmented. Scans that read rows in primary key order jump across many non-sequential pages on disk - random I/O instead of sequential reads.
With ULID or UUID v7 (monotonic):
Every new ID is larger than all previous IDs. Inserts always land at the rightmost leaf of the B-tree, just like AUTO_INCREMENT. Pages fill completely before a new page is allocated. Fragmentation remains near zero.
The performance difference becomes significant at table sizes above ~10 million rows with write-heavy workloads, especially on HDD or under memory pressure where the buffer pool cannot hold the entire index.
PostgreSQL
PostgreSQL does not use clustered indexes by default (heap storage), so the primary key column's B-tree index can still fragment, though the impact is less severe than InnoDB. PostgreSQL also offers the native uuid type (16 bytes) - use it instead of varchar(36) regardless of which UUID format you choose.
Storage: String vs Binary
Storing IDs as strings wastes space and slows comparisons:
| Format | String storage | Binary storage |
|---|---|---|
| UUID v4 / v7 | 36 bytes (varchar) | 16 bytes (binary/uuid) |
| ULID | 26 bytes (varchar) | 16 bytes (binary) |
At 100 million rows, the difference between varchar(36) and binary(16) is over 2 GB on the index alone - before counting row data. For high-volume tables, use BINARY(16) in MySQL or the native uuid type in PostgreSQL.
Security Trade-offs
UUID v4 Is Opaque
UUID v4 reveals nothing about when or where it was generated. A public-facing URL like https://app.example.com/orders/f47ac10b-58cc-4372-a567-0e02b2c3d479 gives an attacker no information about when the order was placed or how many orders exist.
ULID and UUID v7 Expose Creation Time
The first 48 bits of both ULID and UUID v7 encode the Unix millisecond timestamp. Anyone with access to the ID can determine - to the millisecond - when the record was created. For internal primary keys never shown to users, this is irrelevant. For public-facing resource URLs or API responses, consider whether exposing the creation timestamp is acceptable for your use case.
This is not a vulnerability in itself, but it is a privacy consideration worth weighing for your specific context.
Code Examples
PHP
// UUID v4 and v7 - symfony/uid is included with Symfony framework
$uuid4 = \Symfony\Component\Uid\Uuid::v4()->toRfc4122();
// f47ac10b-58cc-4372-a567-0e02b2c3d479
$uuid7 = \Symfony\Component\Uid\Uuid::v7()->toRfc4122();
// 018e2b3d-a1c2-7000-b0e5-4a9f1c8d7e2a
// ULID - symfony/uid also supports ULID
$ulid = new \Symfony\Component\Uid\Ulid();
echo (string) $ulid; // 01ARZ3NDEKTSV4RRFFQ69G5FAV
// Store as binary(16) in MySQL
$binaryUlid = $ulid->toBinary();
Python
import uuid
# UUID v4
uuid4 = str(uuid.uuid4())
# 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
# UUID v7 - requires Python 3.13+ or the 'uuid6' package
# pip install uuid6
import uuid6
uuid7 = str(uuid6.uuid7())
# '018e2b3d-a1c2-7000-b0e5-4a9f1c8d7e2a'
# ULID - pip install python-ulid
from ulid import ULID
ulid = ULID()
str(ulid) # '01ARZ3NDEKTSV4RRFFQ69G5FAV'
ulid.timestamp() # 1469918176.385 (Unix seconds as float)
ulid.milliseconds # 1469918176385
JavaScript / TypeScript
// UUID v4 - native in all modern browsers and Node.js 15+
const uuid4 = crypto.randomUUID();
// 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
// ULID - install the 'ulid' package
import { ulid, decodeTime } from 'ulid';
const id = ulid();
// '01ARZ3NDEKTSV4RRFFQ69G5FAV'
// Extract creation timestamp from a ULID
const timestamp = decodeTime(id);
// 1469918176385 (Unix ms)
SQL: Schema Examples
-- PostgreSQL: native uuid type, no conversion needed
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- MySQL/InnoDB: binary(16) for efficient clustered index
CREATE TABLE events (
id BINARY(16) NOT NULL,
payload JSON,
PRIMARY KEY (id)
) ENGINE=InnoDB;
Extracting the Timestamp
ULID's Base32 encoding makes timestamp extraction readable without library code:
const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
function decodeUlidTime(ulid) {
return ulid.slice(0, 10)
.split('')
.reduce((ts, char) => ts * 32 + ENCODING.indexOf(char), 0);
}
decodeUlidTime('01ARZ3NDEKTSV4RRFFQ69G5FAV');
// 1469918176385 → "2016-07-30T22:56:16.385Z"
UUID v7 timestamp extraction requires a bit more work because the timestamp is split across the standard UUID byte layout, but any UUID v7 library handles it.
When to Use Each
Choose UUID v4 when:
- Maximum ecosystem compatibility is required (legacy systems, third-party integrations expecting the RFC 4122 format)
- You need opaque IDs that do not expose creation timestamps in public-facing URLs
- You are migrating an existing system where changing the ID format carries high risk and cost
- Simplicity matters more than write performance for your scale
Choose ULID when:
- Building a new system with no legacy UUID constraints
- You want shorter, case-insensitive, human-readable IDs for URLs and log files
- Your team wants to decode creation time from IDs without a database query
- You prefer community-driven specifications with wide language support
Choose UUID v7 when:
- You want time-ordered IDs but your infrastructure already handles UUIDs natively (column types, ORMs, REST clients)
- You prefer IETF-standardised formats over community specs
- You are already using Symfony, Laravel, or another framework with built-in UUID v7 support
- You need the B-tree performance benefits with zero schema changes from UUID v4
Conclusion
For new projects, UUID v7 is the pragmatic choice: it is an IETF standard, drop-in compatible with UUID infrastructure, and fixes the database fragmentation problem. ULID is a strong alternative if you value its shorter, more readable format.
For existing projects already using UUID v4: unless you have a measured write performance problem at scale, the migration cost rarely justifies switching. UUID v4 is perfectly adequate for most systems.
Avoid UUID v1, v3, and v5 unless you have a specific deterministic or namespace-based use case.
Try all three: UUID v4, UUID v7, and ULID - all run client-side, no data is sent to a server.