ULID vs UUIDv7: The Standard, Encoding, and DB Compatibility Differences That Determine Your Primary Key Choice
Every time you start a new project, you inevitably find yourself thinking about how to design your primary key. AUTO_INCREMENT quickly hits its limits in distributed environments, and UUIDv4's complete randomness wreaks havoc on B-tree indexes. B-tree indexes are most efficient when values are inserted in order — when fully random values come in, page splits become frequent and write performance degrades noticeably. When looking for an "ID that sorts chronologically" as an alternative, ULID and UUIDv7 always come up together.
I initially thought "aren't they both just UUIDs with a timestamp attached?" — but when I actually sat down to design a table schema, I quickly realized the differences between the two are more significant than I expected. After reading this article, the differences between the two formats across three axes — standardization, encoding, and DB compatibility — will be clear, and you'll be able to make the right choice for your project right away.
If you need an RFC standard or want to keep using your existing UUID infrastructure as-is, UUIDv7 is the right choice. If you're building a new service where URL readability matters and you can accept a non-standard format, ULID is the way to go. Let's walk through the reasoning below.
Core Concepts
ULID — A Community Spec Born for Readability
ULID (Universally Unique Lexicographically Sortable Identifier) is a format proposed by Alizain Feerasta in 2016. It encodes a 48-bit Unix millisecond timestamp and 80 bits of randomness using Crockford Base32 into a 26-character case-insensitive string.
01ARZ3NDEKTSV4RRFFQ69G5FAV
└──────────┘└────────────────┘
Timestamp (10 chars) Random (16 chars)80 bits means 2^80 — roughly a trillion times a trillion possible values. Even at a generation rate of one million per second, the time it would take for a collision to occur is astronomically large, making it safe to use in distributed environments without any central coordination.
Crockford Base32 removes characters like O, I, L, and U that are easily confused with numbers or other characters. This reduces typos when humans read or type them directly, and the resulting string is URL-safe, meaning it can be used as-is in paths like /posts/01ARZ3NDEKTSV4RRFFQ69G5FAV.
Crockford Base32: A base-32 encoding scheme using digits 0–9 and 22 alphabet characters (excluding confusable characters like O, I, L, U). More human-friendly than standard Base32 and case-insensitive.
UUIDv7 — An RFC Standard Officially Published by the IETF in 2024
UUIDv7 is an IETF standard officially published as RFC 9562 in May 2024. Its structure is 128 bits: a 48-bit timestamp + 4-bit version + 12-bit rand_a + 2-bit variant + up to 62-bit rand_b. Its appearance is the familiar hyphenated UUID format we've always seen.
018f7c83-6afe-7e3d-b1a2-4c5d6e7f8a9b
└─────────────┘
48-bit timestamp (sorted at the front)RFC 9562: A UUID standards document officially published by the IETF (Internet Engineering Task Force) in May 2024. It replaces RFC 4122, which defined UUIDv1–v5, and newly defines v6, v7, and v8.
UUIDv7's biggest advantage is its complete compatibility with existing UUID columns. You can switch the generation logic from UUIDv4 to UUIDv7 without touching your schema at all, making it especially appealing for services with legacy systems.
One thing worth knowing is that using the 12-bit rand_a as a counter reduces the effective random bits. RFC 9562 delegates how rand_a is used to the implementation, so depending on the library, effective random bits can be up to 74 bits.
Placing the Two Formats Side by Side
Both formats place the timestamp at the front for chronological sorting and can be generated independently without central coordination in distributed environments. They're both designed to solve the fragmentation problem that UUIDv4's full randomness caused in B-tree indexes. The real differences lie in "how they're packaged."
| Item | ULID | UUIDv7 |
|---|---|---|
| Standardization | Community spec (non-standard) | IETF RFC 9562 official standard |
| Encoding | Crockford Base32 (26 chars) | Hyphenated UUID text (36 chars) |
| Binary size | 16B | 16B |
| Random bits | 80 bits | Up to 74 bits (varies by configuration) |
| Native DB support | None | PostgreSQL 18, MariaDB 11.7 |
| Existing UUID column compatibility | Not compatible | Fully compatible |
Practical Application
Applying UUIDv7 in a New PostgreSQL Service
PostgreSQL 18 added a built-in uuidv7() function. It's a good idea to verify the current release status and exact version with SELECT version(); (as of May 2026). You can generate IDs directly at the DB layer without external libraries and continue using the existing uuid column type.
-- PostgreSQL 18 and above
CREATE TABLE posts (
id UUID DEFAULT uuidv7() PRIMARY KEY,
title TEXT NOT NULL,
body TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Insert example
INSERT INTO posts (title, body) VALUES ('First post', 'Content...');
-- Chronological sorting (index-efficient)
SELECT * FROM posts ORDER BY id;
-- Extract timestamp from ID (PostgreSQL 18 new feature)
SELECT uuid_extract_timestamp(id) FROM posts LIMIT 5;If you want to generate IDs in your app rather than in the DB, you can do it like this.
// TypeScript — uuid package v10+
import { v7 as uuidv7 } from 'uuid';
// TypeORM entity — use @BeforeInsert instead of initializing as an instance field
// to avoid the problem of TypeORM overwriting the value with null
@Entity()
export class Post {
@PrimaryColumn('uuid')
id: string;
@BeforeInsert()
generateId() {
if (!this.id) {
this.id = uuidv7();
}
}
@Column()
title: string;
}# Python — pip install uuid7
from uuid7 import uuid7
post_id = uuid7()
print(str(post_id))
# '018f7c83-6afe-7e3d-b1a2-4c5d6e7f8a9b'| Key Point | Details |
|---|---|
DEFAULT uuidv7() |
DB-level generation — chronological ordering always guaranteed without depending on app code |
uuid_extract_timestamp() |
Creation time can be extracted without a separate created_at column |
@BeforeInsert() hook |
Less conflict with TypeORM than directly initializing instance fields |
Using ULID in Services That Expose IDs in URLs
When IDs are embedded directly in API paths or shared URLs, ULID's 26-character string can be used as-is. Putting a UUID like 018f7c83-6afe-7e3d-b1a2-4c5d6e7f8a9b in a URL is both long and visually complex with hyphens, while ULID has no hyphens and is about 10 characters shorter. Note that hyphens themselves are unreserved characters per RFC 3986 and do not require percent-encoding. ULID's URL advantage lies not in encoding but in its shorter 26-character length and case-insensitivity.
// TypeScript — ulid package
import { ulid } from 'ulid';
const postId = ulid();
// '01ARZ3NDEKTSV4RRFFQ69G5FAV'
// Express.js router example
app.get('/posts/:id', async (req, res) => {
// URL: /posts/01ARZ3NDEKTSV4RRFFQ69G5FAV
const post = await db.posts.findOne({ id: req.params.id });
res.json(post);
});// Go — oklog/ulid/v2 (crypto/rand-based entropy)
import (
"crypto/rand"
"github.com/oklog/ulid/v2"
"time"
)
func generateID() string {
t := time.Now()
// Use crypto/rand instead of math/rand + nanosecond seed
// to eliminate the risk of seed collisions under concurrent requests
entropy := ulid.Monotonic(rand.Reader, 0)
return ulid.MustNew(ulid.Timestamp(t), entropy).String()
// "01ARZ3NDEKTSV4RRFFQ69G5FAV"
}When storing ULIDs in PostgreSQL, the choice of column type matters. Inserting a ULID into a uuid type column will be rejected by the DB.
-- Option 1: TEXT column (simple, but uses 10B more than binary)
CREATE TABLE posts (
id TEXT PRIMARY KEY CHECK (length(id) = 26),
title TEXT NOT NULL
);
-- Option 2: BYTEA column (16B, requires binary conversion)
CREATE TABLE posts (
id BYTEA PRIMARY KEY,
title TEXT NOT NULL
);| Key Point | Details |
|---|---|
crypto/rand entropy |
Cryptographically secure entropy source — no collision risk even under high concurrent load |
ulid.Monotonic() |
Guarantees monotonic increase even within the same millisecond |
TEXT vs BYTEA |
Use TEXT for readability, BYTEA (16B) for storage efficiency |
Migrating from Legacy UUIDv4 to UUIDv7
A common real-world scenario: your existing service uses UUIDv4, you're hitting index performance issues, and you need to switch to a chronologically sortable ID. In this case, going with ULID requires overhauling the entire schema. You'd need to change existing uuid columns to TEXT or BYTEA, along with cascading changes to foreign keys, indexes, and ORM configuration. UUIDv7 uses the same format, so you only need to swap the generation logic.
-- Existing table (no changes needed)
CREATE TABLE users (
id UUID PRIMARY KEY, -- keep as-is
email TEXT UNIQUE NOT NULL
);
-- Before migration: gen_random_uuid() (UUIDv4)
INSERT INTO users (id, email) VALUES (gen_random_uuid(), 'user@example.com');
-- After migration: uuidv7() (PostgreSQL 18)
ALTER TABLE users ALTER COLUMN id SET DEFAULT uuidv7();
-- Only the default value is replaced, no schema structure changes// Java Spring Boot — uuid-creator library
// The strategy in @GenericGenerator requires an IdentifierGenerator implementation.
// Since uuid-creator provides a separate Hibernate adapter,
// generating directly via @PrePersist is safer and more portable.
import com.github.f4b6a3.uuid.UuidCreator;
import java.util.UUID;
@Entity
public class User {
@Id
@Column(columnDefinition = "uuid")
private UUID id;
@PrePersist
protected void onCreate() {
if (this.id == null) {
this.id = UuidCreator.getTimeOrderedEpoch(); // UUIDv7
}
}
}| Key Point | Details |
|---|---|
ALTER COLUMN id SET DEFAULT |
Only the default value is replaced — existing data is untouched |
Preserving UUID type |
Foreign keys, indexes, and ORM mappings can all be reused as-is |
@PrePersist |
ID generation via Hibernate lifecycle callback — direct control without library adapters |
Pros and Cons Analysis
Advantages
Both formats equally improve B-tree index efficiency through chronological sorting, but each has its own areas of strength.
UUIDv7's biggest strength is ecosystem support. Being an official IETF standard, native functions have landed in PostgreSQL 18 and MariaDB 11.7, and .NET 9 includes Guid.CreateVersion7() in the standard library. Python's standard uuid module is also expected to support UUIDv7 in version 3.14. The ability to continue using existing UUID infrastructure as-is is a particularly decisive advantage for organizations with legacy systems.
ULID's core strength is that it's designed to be easy for humans to work with. Its short, clean 26-character string fits naturally in URLs, and with no confusable characters, typos are reduced when reading or typing manually. Think about having to copy an ID while reading logs — a clean 26-character string is far preferable to a 36-character hyphenated one.
| Item | ULID | UUIDv7 |
|---|---|---|
| Standardization | Community spec | IETF RFC 9562 official standard |
| Native DB support | None | PostgreSQL 18, MariaDB 11.7 |
| Existing UUID column compatibility | Not compatible | Fully compatible |
| URL readability | High (26 chars, no hyphens) | Moderate (36 chars, with hyphens) |
| Random bits | 80 bits | Up to 74 bits |
| Language ecosystem | Mature in JS/Go/Rust/Python | Rapidly expanding |
Drawbacks and Caveats
There are some things you must address before adopting either format. Overlooking these in production can lead to quite painful situations later.
⚠️ Security Warning: Both ULID and UUIDv7 embed the creation timestamp in the ID. It is advisable not to use these formats where unpredictability is critical, such as API keys, session tokens, or CSRF protection tokens. UUIDv4 or
crypto.randomBytes()remains the appropriate choice for such use cases.
| Item | Details | Mitigation |
|---|---|---|
| Timestamp exposure (common) | Creation time can be extracted from the ID | Keep UUIDv4 for security-sensitive tokens |
| ULID — non-standard | Community spec, long-term support uncertain | Accept external library dependency; prepare a UUIDv7 migration plan if needed |
| ULID — DB compatibility issue | Cannot be stored in PostgreSQL uuid type |
Explicitly choose TEXT or BYTEA column in new schemas |
| ULID — encoding overhead | Speed difference versus native UUID generation due to Base32 encoding cost | Benchmark directly before deciding in scenarios generating millions per second |
| UUIDv7 — no Java support | Not supported in JDK standard library (JDK-8357251 under discussion) | Use third-party libraries like uuid-creator |
| MySQL not supported | MySQL still relies on app-layer generation (MariaDB 11.7 has native support) | Use app-layer libraries as a substitute in MySQL environments |
The Most Common Mistakes in Practice
- Using ULID/UUIDv7 for security tokens: Both formats embed a timestamp. UUIDv4 or
crypto.randomBytes()is appropriate where unpredictability matters, such as API keys and session tokens. - Attempting to store ULID in a UUID column: Inserting a ULID string into a
uuidtype column will be rejected by the DB. When an ORM automatically creates UUID columns, you need to explicitly override the column type toTEXTorBYTEA. - Assuming sub-millisecond sort order is guaranteed: Both formats sort at millisecond granularity. To guarantee insertion order within the same millisecond, it's best to explicitly enable ULID's
Monotonicoption or UUIDv7'srand_acounter method.
Closing Thoughts
If you need an RFC standard or have legacy UUID infrastructure, go with UUIDv7. If you're building a new service where URL readability matters and you can accept a non-standard format, ULID is the right choice. Having applied this in a pilot, I was able to confirm from EXPLAIN ANALYZE results that simply switching the generation logic from UUIDv4 to UUIDv7 on an existing event table clearly changed the index scan behavior — without touching a single line of schema.
Three steps you can start with right now:
- Audit your existing codebase:
grep -r "gen_random_uuid\|uuid4\|UUID.randomUUID" .lets you identify where UUIDv4 is currently being used. If you have existinguuidcolumns, migrating to UUIDv7 will be much smoother. - Check your DB version: For PostgreSQL, use
SELECT version();; for MariaDB, useSELECT VERSION();. PostgreSQL 18 and above supports the built-inuuidv7()function directly, and MariaDB 11.7 and above supports native UUIDv7 functions. For older versions, consider app-layer libraries (uuidv10+,uuid7package, etc.). - Run a pilot on a small table: Rather than tackling the entire users table, try applying it first to a lower-stakes log or event table. You can visually confirm the change in index scan behavior with
EXPLAIN ANALYZE SELECT * FROM events ORDER BY id LIMIT 100;.
References
- UUIDv7 is coming to PostgreSQL 18 — jypark.pe.kr (Korean)
- RFC 9562 — Universally Unique IDentifiers (UUIDs)
- ULID Spec (Official GitHub)
- UUIDv7 Comes to PostgreSQL 18 — The Nile
- PostgreSQL 18 UUIDv7 Support — Neon
- Exploring PostgreSQL 18's new UUIDv7 support — Aiven
- Goodbye Random Inserts: UUIDv7 vs ULID vs UUIDv4 — Medium (Ahmed K Emara)
- UUID v4 vs v7 vs ULID: Choosing the Right ID Format in 2026 — codetools.run
- Comprehensive comparison of UUID v4, v7, and ULID — iXam
- A Comparative Analysis of Identifier Schemes: UUIDv4, UUIDv7, and ULID — arXiv
- Stop using UUID v4 — UUIDv7 is the 2026 default — DEV Community
- An Introduction to ULIDs — ByteAether