# UUID vs ULID: Which Identifier Should Your Application Use?

> Compare UUID v4, UUID v7, and ULID for sortability, database indexes, URL safety, and distributed systems, with a clear recommendation for new projects.

**Quick answer:** Use UUID v7 or ULID when you need time-sortable identifiers that reduce index fragmentation; use UUID v4 when randomness and broad library support matter more than ordering. Both are 128-bit values, but ULID is encoded as 26 readable characters and UUID as 36 characters with hyphens.

## Key takeaways

- UUID v4 is random; UUID v7 and ULID embed a timestamp and sort approximately by creation time.
- Time-sortable IDs improve B-tree locality and make recent records faster to query.
- ULID uses Crockford Base32 and is case-insensitive; UUID uses hexadecimal with hyphens.
- Neither format is a secret. Do not rely on unpredictability for authorization.
- Store the binary form when possible and expose the canonical text form at the API boundary.

---

Choosing an identifier format looks like a minor decision until the table grows. Random primary keys can fragment indexes, sortable keys can leak creation order, and text representations affect URLs, logs, and debugging.

This guide compares UUID v4, UUID v7, and ULID on the dimensions that matter in practice.

## The formats at a glance

| Property | UUID v4 | UUID v7 | ULID |
| --- | --- | --- | --- |
| Bits | 128 | 128 | 128 |
| Time-ordered | No | Yes | Yes |
| Text length | 36 with hyphens | 36 with hyphens | 26 |
| Alphabet | Hex `0-9a-f` | Hex | Crockford Base32 |
| Case-sensitive | No | No | No |
| Standard | RFC 9562 | RFC 9562 | ULID spec |
| Library support | Very broad | Growing | Good |

UUID v4 uses 122 random bits. UUID v7 uses a 48-bit millisecond timestamp plus random bits. ULID uses a 48-bit millisecond timestamp plus 80 random bits and encodes the result in Crockford Base32.

The bit budget explains the tradeoffs:

| Format | Timestamp bits | Random or counter bits | Text length |
| --- | --- | --- | --- |
| UUID v4 | 0 | 122 | 36 |
| UUID v7 | 48 | 74 (12-bit `rand_a` + 62-bit `rand_b`) | 36 |
| ULID | 48 | 80 | 26 |

A 48-bit millisecond timestamp runs out in the year 10889, so overflow is not a practical concern. The random portion determines collision behavior within the same millisecond. UUID v7 has 74 random bits per millisecond, ULID has 80, and both are far more than enough for application traffic; a system generating a million IDs per millisecond would still have a negligible birthday-collision probability. The real difference is text length and ecosystem support, not raw uniqueness.

ULID's first character is always between `0` and `7`. That is not a convention: 48 bits of timestamp fit into 10 Base32 characters (50 bits), so the leading character can only use the low 3 bits. A validator that accepts `Z` as the first character of a ULID is accepting a value that cannot decode to a valid 48-bit timestamp.

## Why sortability matters

A B-tree index stores keys in order. When new keys are random, inserts land across the tree, causing page splits and write amplification. When new keys are roughly sequential, inserts append to the rightmost pages.

This is the main reason teams move from UUID v4 to UUID v7 or ULID for high-write tables. The benefit is largest for:

- Tables with many inserts per second.
- Clustered indexes, such as MySQL InnoDB primary keys.
- Time-series-like data where recent rows are queried most.

The benefit is smaller for low-write tables or tables keyed by a separate sequential column.

The mechanism is worth understanding because it determines when the advice applies. In a B-tree, a random insert has to find a leaf page that may be full, split it, and update every ancestor; the new pages are scattered across the file. A sequential insert usually appends to the rightmost leaf, leaving the rest of the tree untouched. InnoDB's clustered primary key makes this especially visible because the entire row lives in the primary-key B-tree, so a random key moves row data, secondary-index entries, and undo records. PostgreSQL's heap is separate from its indexes, so the primary-key index still fragments but the row insert itself is append-only; the effect is real but smaller.

You can see the problem in monitoring rather than guess it. In PostgreSQL, `pg_stat_user_tables.n_dead_tup` and index bloat estimates rising faster than row count suggest random-key churn. In MySQL, `INFORMATION_SCHEMA.INNODB_TABLESTATS` and a sudden rise in `Innodb_buffer_pool_pages_free` pressure during inserts are signals. A before-and-after benchmark on a copy of the table is more convincing than a blog post's percentage.

Sortability does not help every workload. If reads are random by ID, a time-ordered key does not improve lookup; if the table is small enough to fit in memory, page splits are cheap; if the application already sorts by a separate `created_at` index, the primary-key order only affects insert cost. And a time-ordered key can concentrate writes on one hot page, which becomes a contention point at very high insert rates. UUID v7 implementations that add a monotonic counter reduce this, but they also make the ID less random.

## Why randomness matters

Time-ordered identifiers leak creation time. If an ID is exposed publicly, anyone can estimate when a record was created. That is usually acceptable, but consider the context:

- Public order IDs may reveal business volume.
- Sequential IDs reveal how many records exist.
- Time-ordered IDs reveal approximate creation time.

UUID v4 hides all of that. If the timestamp is sensitive, use v4 or a separate opaque public ID.

A UUID v7 or ULID exposes the creation time to the millisecond. From a single public ID, an observer can derive the exact second a record was created, and from a sequence of IDs they can estimate signup rate, order volume, and which accounts were created around the same time. Truncating the timestamp before encoding is not a standard operation and breaks the format's uniqueness guarantees, so the practical mitigation is a second identifier: keep the time-ordered value as the internal primary key and expose a random `public_id` for URLs and APIs. That costs 16 extra bytes per row and a unique index, which is usually cheaper than leaking business metrics or enabling correlation across tenants.

None of these formats is a secret. An ID is not an authorization token. Use server-side access control regardless of format.

## Text representation and URLs

ULID is shorter and easier to read:

```text
UUID v4: 550e8400-e29b-41d4-a716-446655440000
UUID v7: 018f4f8e-7c2a-7c3e-9b1a-2f6d8e4a1b3c
ULID:    01JCH6X8Y4Q2V7M3N9K5R1T6B8
```

ULID uses Crockford Base32, which excludes `I`, `L`, `O`, and `U` to reduce confusion, and is case-insensitive. That makes it friendlier for copy-paste, phone support, and human transcription.

UUID is more recognizable and supported by more libraries, databases, and external systems. If you integrate with many third-party services, UUID is the safer default.

Crockford Base32 has a detail that trips up validators. It excludes `I`, `L`, `O`, and `U` when encoding, but a decoder should accept `i` and `l` as `1`, and `o` as `0`, because those characters are the ones people confuse when transcribing. ULID is case-insensitive, so `01jch6x8...` and `01JCH6X8...` are the same value, but storing both forms in a case-sensitive column creates duplicate-looking rows and broken equality checks. Normalize to uppercase on write, and use `BINARY(16)` or a binary/ASCII collation if you must store text; a case- and accent-insensitive collation makes `A` and `a` compare equal, which is convenient for UUIDs but hides the fact that your canonical form was not preserved.

Validation should reject the whole value, not repair it. The `ulid` package's `decodeTime("not-a-ulid")` throws `ULIDError: Malformed ULID (DEC_TIME_MALFORMED)`, while `isValid` returns `false`; both are preferable to a permissive regex that accepts 26 characters and then fails later. For UUIDs, a canonical v4/v7 string is 36 characters with hyphens in fixed positions; a 32-character hex string is valid input to many parsers but not the canonical form. On SQL Server, `uniqueidentifier` values sort in a byte order that differs from the canonical string order, so ordering by the column does not match ordering by the text representation. Normalize at the boundary rather than comparing raw strings from different sources.

The URL footprint differs more than the character count suggests. A UUID needs 36 characters, plus encoding overhead if it appears in a path; a ULID needs 26 and avoids hyphens, which makes it easier to double-click and safer in CSV exports. Neither is short enough to replace a human-readable slug, and neither should be used as a password-reset token.

## Database storage

Both formats are 128-bit values. Store them as:

- `BINARY(16)` or `UUID` in databases that support a native type.
- Two `BIGINT` columns if the database lacks a 128-bit type.
- Text only when the convenience outweighs the storage and index cost.

Text storage doubles or triples the size and slows comparisons. If you store text, use a fixed-length type and a consistent case.

Concrete numbers make the cost visible. A `BINARY(16)` column stores 16 bytes per value; a `CHAR(36)` column using `utf8mb4` reserves up to 144 bytes in InnoDB, and its index entries carry the same width. On a table with 10 million rows, that is roughly 1.4 GB of extra index data before accounting for page overhead. `CHAR(36) CHARACTER SET ascii COLLATE ascii_bin` is a reasonable compromise when a text column is unavoidable, because ASCII characters take one byte each and the binary collation gives deterministic comparisons.

Database-specific behavior matters more than the generic advice. MySQL's `UUID_TO_BIN(uuid, 1)` swaps the time-low and time-high fields so the binary form sorts by time; without the swap flag, the binary order does not match the UUID v1 order, and a v7 value is already time-ordered in text form, so the swap can actually break the ordering you wanted. PostgreSQL 18 added native `uuidv7()` and `uuidv4()` functions, which avoids a round trip to the application for the timestamp. In SQLite, store the value as a `BLOB` of 16 bytes or as canonical text with a `CHECK(length(id) = 26)` constraint for ULIDs; a `TEXT` column with mixed case and no constraint will accumulate inconsistent values.

The index type matters too. A `BINARY(16)` primary key in InnoDB is clustered, so the 16 bytes are repeated in every secondary index; a 36-byte text key repeats 36 bytes instead. Over millions of rows and several secondary indexes, the difference in index size and cache pressure is larger than the per-row arithmetic suggests.

## Generating IDs

```js
// UUID v4 (random)
const id = crypto.randomUUID();

// ULID (time-ordered) with a library
import { ulid } from "ulid";
const id = ulid();
```

For UUID v7, use a library that implements RFC 9562 rather than constructing the timestamp bits yourself. A small mistake in the bit layout produces values that look valid but sort incorrectly.

The [UUID/ULID generator](/tools/uuid-ulid) can produce both formats for testing. The [GUID generator](/tools/guid-generator) is useful when a Windows-oriented system expects GUID formatting.

Generation has a few operational details. `crypto.randomUUID()` is only available in secure contexts in browsers, so a page served over plain HTTP on a LAN will throw `TypeError: crypto.randomUUID is not a function`; use a library or the `uuid` package's fallback. Most runtimes' `randomUUID()` produces v4, not v7, so "use the built-in" is not enough when you need time ordering. A v7 library such as `uuid`'s `v7()` or a ULID library's `monotonicFactory()` handles the bit layout and, in the ULID case, increments the random component when the clock does not advance, which keeps IDs from the same process sortable.

Monotonicity is per process and per clock. Two application servers whose clocks differ by 500 ms can generate IDs in an order that contradicts real time; the IDs remain unique, but "latest record" queries and cursor pagination may skip or duplicate rows. NTP reduces the skew but does not eliminate it, and a clock step backward can produce a v7 value earlier than one already issued. If strict global order is a correctness requirement, do not derive it from wall-clock IDs; use a centralized sequence, a database-generated value, or sort by a server-assigned timestamp with a tiebreaker.

## When to use each

**Use UUID v4 when:**

- You need maximum library and database compatibility.
- You do not want to expose creation time.
- The table has low write volume or uses a separate sequential key.
- You are integrating with systems that only accept UUID.

**Use UUID v7 when:**

- You want time-ordered inserts and UUID compatibility.
- Your database or ORM supports v7 generation.
- You need a standard format with a defined bit layout.

**Use ULID when:**

- You want a compact, case-insensitive text ID.
- You control the full stack and can add a ULID library.
- You want time-ordered IDs with a human-friendly representation.

The decision tree is short. If the system already speaks UUID and the table has heavy inserts, choose UUID v7. If you control the whole stack and the text form matters to humans, choose ULID. If you need opacity more than ordering, or the ecosystem only accepts UUID, choose v4. If none of those fit, consider a different identifier family:

| Format | Size | Ordered | Best fit |
| --- | --- | --- | --- |
| Auto-increment `BIGINT` | 8 bytes | Yes | Single-database internal keys |
| Snowflake-style ID | 8 bytes | Yes | Distributed systems with worker IDs |
| KSUID | 20 bytes | Yes | 27-character Base62 IDs, time-sortable |
| NanoID | ~21 characters | No | Short random IDs for UI or filenames |
| UUID v7 | 16 bytes | Yes | Standard tooling with index locality |
| ULID | 16 bytes | Yes | Compact text IDs you control end to end |

The recommendation is wrong when IDs must be unpredictable and creation time is sensitive: v7 and ULID both leak time, and a separate random public identifier is the fix, not a different timestamp encoding. It is also wrong when the database is a single Postgres instance with a `BIGINT` sequence and no distributed writers; a 64-bit integer is smaller, faster, and simpler than any 128-bit format. Choose the identifier for the system you have, not the one in the conference talk.

## Common mistakes

- Generating UUIDs in the application and sorting by a separate timestamp that can drift.
- Using a UUID as a password reset token or API key. Use a dedicated random token with enough entropy and store only its hash.
- Storing UUIDs as case-sensitive text and comparing them with different casing.
- Assuming time-ordered IDs are strictly monotonic. Clock adjustments can produce out-of-order values; ULID includes a monotonic mode in some implementations, but you should not rely on total ordering for correctness.
- Exposing sequential or time-ordered IDs without access control.

Two of these deserve a concrete symptom. Case-insensitive storage plus case-sensitive comparison produces a support ticket that is hard to reproduce: the user pastes an uppercase ID from an email, the API looks up the lowercase value, and the row exists but is reported as not found. Test the lookup with both cases in an integration test. The monotonicity assumption shows up in cursor pagination: if two rows share a millisecond and the random tiebreaker is not stable, a `WHERE id > $cursor ORDER BY id` query can skip the second row or return it twice. Use a deterministic tiebreaker such as `(id, created_at)` or paginate by both columns.

A third mistake is treating the ID as a secret. A random UUID v4 is unguessable, but if it also functions as the only access check, a leaked link grants access forever. Pair public IDs with authorization, expiry, and revocation; the identifier format is not a security control.

## A practical recommendation

For a new application with a modern database, UUID v7 is a strong default: it is standardized, time-sortable, and compatible with UUID tooling. If you want shorter text IDs and control the stack, ULID is a good alternative. Use UUID v4 when compatibility or unpredictability is the dominant requirement.

Whichever you choose, document the format, store the binary form when possible, and keep authorization separate from identifier generation.

Migrating an existing table from v4 to v7 is not a format change; it is an index rebuild. Add a new column, backfill it in batches ordered by the old `created_at` so the new keys are mostly sequential, switch reads and writes in a controlled rollout, and rebuild the primary key when the old column is no longer needed. Expect the table and its indexes to grow during the transition, and do it in a maintenance window or with online schema-change tooling. A migration that generates v7 values in random order destroys the index-locality benefit you switched for.

Before committing, verify the choice with a short checklist: generate 10 million IDs and confirm no duplicates and the expected sort order; insert them into a copy of the production table and measure index size, write throughput, and `EXPLAIN` plans; check that every consumer accepts the text length and alphabet; confirm case normalization and input validation; and document the timestamp range and clock assumptions. If the measured benefit is within noise, the simpler format wins.

## Frequently asked questions

### Is ULID better than UUID?

Neither is universally better. ULID and UUID v7 both provide time-ordered identifiers. UUID has broader library and database support, while ULID has a shorter, case-insensitive text representation. Choose based on your ecosystem.

### Can UUID v4 values be sorted by creation time?

No. UUID v4 values are random, so their lexical order has no relationship to creation time. Sorting by a separate created_at column is required. UUID v7 and ULID embed a timestamp and sort approximately by time.

### Are UUIDs safe to use as public identifiers?

They are not secrets. A UUID can be exposed without revealing a sequential count, but it does not grant authorization. Always enforce access control on the server; do not treat an unguessable ID as a permission token.

### What is the collision risk of UUID v4?

The probability of a collision is negligible for practical systems because UUID v4 contains 122 random bits. ULID and UUID v7 combine a timestamp with random bits, so collision risk remains negligible when the random portion is large enough.

### Should I use UUIDs as database primary keys?

They work, but random UUIDs can fragment clustered indexes. UUID v7 and ULID reduce fragmentation because new values are roughly sequential. Measure the impact on your database rather than assuming.

## References

- [RFC 9562 - Universally Unique IDentifiers (UUIDs)](https://www.rfc-editor.org/rfc/rfc9562)
- [ULID specification](https://github.com/ulid/spec)
- [RFC 4122 - A UUID URN Namespace](https://www.rfc-editor.org/rfc/rfc4122)
- [Crockford Base32](https://www.crockford.com/base32.html)

---

- Canonical: https://www.jsonmind.com/guides/uuid-vs-ulid
- Locale: en
- Published: 2026-11-09
- Updated: 2026-11-09
- Tools: uuid-ulid, guid-generator, random-token-generator, base32