Differences between ULID and UUIDv7

2026-05-09 03:00 (6 months ago)
Hex Badge
Play a song themed on this article

There are two 128-bit time-sortable IDs: ULID and UUIDv7. Both solve the same problem, a "monotonically increasing random ID", but they differ in design philosophy, level of standardization, and ecosystem. This article sorts out the differences between the two, and covers the current library situation in Python, TypeScript, and Rust, and the concrete way to write the code when you adopt them as primary keys in Django.

TL;DR

  • 128 bits, with Unix time (ms) in the leading 48 bits: this core is common to both. The strings can be sorted in chronological order.
  • UUIDv7 is an IETF standard (RFC 9562, published May 2024). ULID is a standalone specification on GitHub (since 2016). There is a clear gap in the level of standardization.
  • Python 3.14 (released October 2025) added uuid.uuid7() to the standard library. PostgreSQL 18 (released September 25, 2025) provides a native uuidv7() function. With the combination of Django 5.2 + Python 3.14 + Postgres 18, you can now write UUIDv7 primary keys without external libraries.
  • My conclusion: the primary key for persistent data tied to users is UUIDv7. When you look at the string you can tell it is an ID, and you can tell it is v7. That readability beats ULID's 26-character Base32 string. ULID is better suited to internal identifiers and slugs shown in URLs.

1. The fundamental difference between UUIDv4 and ULID / UUIDv7 is sortability

First, before the difference between ULID and UUIDv7, the difference between UUIDv4 and ULID / UUIDv7.

UUIDv4 fills 122 of the 128 bits, everything except the 6 version/variant bits, with pure randomness.

  • It can generate unique IDs in a distributed environment without coordination → the same is true of v7/ULID.
  • It contains no time information at all → generation order and sort order are unrelated.
  • For secure tokens you should use something else (such as secrets.token_urlsafe()); UUIDv4 is originally meant for "IDs that do not collide".

The shared motivation of ULID and UUIDv7 is to meet the need "I want distributed uniqueness, but I also want to sort by generation order". Conversely, if you don't need sorting and randomness is what you want (e.g. a substitute for a public token you don't want guessed, or a randomly distributed sharding key), there are cases where UUIDv4 is more appropriate.

The specification common to both is as follows.

Common specification Detail
Total length 128 bits (16 bytes)
Leading field 48-bit Unix timestamp (millisecond precision)
Remaining 80 bits Random + (implementation-dependent) monotonicity guarantee
Binary representation Same 16 bytes, mutually convertible

So in terms of storage they are completely equivalent, and the differences are in encoding format, standardization, and the surrounding ecosystem.

2. Historical background

ULID (2016)

  • Author: Alizain Feerasta.
  • Repository: ulid/spec
  • Motivation: to deal with the complaints about UUIDv4, "the string is long and it cannot be sorted".
  • Specification: encode 128 bits as 26 characters in Crockford Base32.
  • Standardization: not an IETF standard but a standalone specification on GitHub. It was later organized as "ULID Spec v1", but it is not an RFC.
  • There are more than 40 implementations across languages (Go's oklog/ulid is the de facto reference implementation).

UUIDv6, v7, v8 (May 2024)

  • Standardized by the IETF as RFC 9562. A revision of RFC 4122.
  • v6: the existing v1 (MAC + time) reordered so that it is time-sortable. A remedy for backward compatibility.
  • v7: the ULID-like approach brought into the UUID format. This is the de facto mainstream.
  • v8: a slot for fully custom implementations (allows application-specific bit layouts).

UUIDv7 is the IETF absorbing the problem raised by ULID into a standard. Historically ULID came first, and UUIDv7 was standardized later, based on that track record.

3. Bit layout differences

ULID

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                      32_bit_uint_time_high                    |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|     16_bit_uint_time_low      |       16_bit_uint_random      |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                       32_bit_uint_random                      |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                       32_bit_uint_random                      |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  • Leading 48 bits: Unix time (ms)
  • Remaining 80 bits: entirely random
  • Monotonicity: if several are generated within the same ms, it is guaranteed by adding 1 to the lowest bit of the random part (monotonic mode). This is not a MUST in the spec but the implementation's responsibility.

UUIDv7 (RFC 9562)

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                           unix_ts_ms                          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|          unix_ts_ms           |  ver  |       rand_a          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|var|                        rand_b                             |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                            rand_b                             |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  • Leading 48 bits: Unix time (ms)
  • 4 bits: version (fixed 0111 = 7)
  • 12 bits: rand_a (the RFC recommends using it as a sub-ms counter or as randomness)
  • 2 bits: variant (10)
  • 62 bits: rand_b

Key difference

UUIDv7 always has the version/variant bits as fixed values. What can be used for randomness is 74 bits in total. ULID can use all 80 bits for randomness. ULID's collision probability is slightly lower, but 74 bits is practical enough, so it is not a problem.

Sub-millisecond monotonicity (Methods 1, 2, 3 in RFC 9562)

RFC 9562 allows several methods for guaranteeing sub-millisecond monotonicity.

  • Method 1 (Fixed-Length Dedicated Counter Bits): dedicate part of rand_a (or the high bits of rand_b) as a fixed-length counter. Within the same ms the counter is incremented by 1, and it is reset when a new ms starts.
  • Method 2 (Monotonic Random): within the same ms, treat the random part of the previously generated value as a "randomly seeded counter" and add 1 to it. No fixed counter region is made; the whole random region is used for monotonic increase.
  • Method 3 (Replace Leftmost Random Bits with Increased Clock Precision): put a sub-ms precision fraction, which divides the millisecond further (in units of 1/4096 ms), into the 12 bits of rand_a. PostgreSQL 18 uses this Method 3.

→ Which Method is used differs by library, so "whether generation order matches the sort order of UUIDv7 strings within the same session" depends on the implementation.

4. String representation differences

ULID

01ARZ3NDEKTSV4RRFFQ69G5FAV
  • Crockford Base32 (5 bits per character × 26 characters = 130 bits; with the constraint that the top 2 bits of the first character are always 0, it is effectively 128 bits. The first character can only be 0–7)
  • Uppercase only. I L O U are excluded (to avoid misreading and profanity)
  • No hyphens, 26 characters
  • At a glance it looks like a "random token"

UUIDv7

0190b3a1-7c4b-7abc-89de-f0123456789a
              ^
              leading 7 → version 7
  • Hex, hyphen-separated, 36 characters (8-4-4-4-12)
  • The first character of the 3rd group (15th character counting hyphens / 13th hex character) is fixed to 7 → you can tell by eye "this is UUIDv7"
  • The first character of the 4th group (20th character counting hyphens / 17th hex character) is one of 8/9/a/b → variant 10

Subjective usability evaluation

With UUIDv7, "a human who looks at it can tell it is an ID". Anyone who sees 0190b3a1-7c4b-7abc-... recognizes it as a UUID from the way the hyphens divide it, and the 7 at the start of the 3rd group tells you right away that it is v7. It does not look out of place when it appears in an API response or an admin page URL.

ULID's 01ARZ3NDEKTSV4RRFFQ69G5FAV cannot be told apart by eye from a JWT or a random token. When this is in a server response, for a second I get wary: "has a secret token ended up in here?" It is a matter of getting used to it, but the cognitive load when reading someone else's code in code review is different.

→ For the primary keys of databases tied to users, I adopt UUIDv7. That is my policy.

5. Usage in each language

Python

UUIDv7

Python 3.14+ (released October 2025)

uuid6, uuid7, and uuid8 were added to the standard library uuid module.

import uuid

new_id = uuid.uuid7()
print(new_id)
# UUID('0190b3a1-7c4b-7abc-89de-f0123456789a')

# Extract the Unix time (ms) from the upper 48 bits
ts_ms = (new_id.int >> 80) & 0xFFFFFFFFFFFF
Python 3.13 and earlier

It is not in the standard library, so use the uuid6 package.

pip install uuid6
from uuid6 import uuid7

new_id = uuid7()

ULID

Not in the standard library. python-ulid is the de facto standard.

pip install python-ulid
from ulid import ULID

new_id = ULID()
print(str(new_id))           # '01ARZ3NDEKTSV4RRFFQ69G5FAV'
print(new_id.timestamp)      # datetime
print(new_id.to_uuid())      # express the same 128 bits as a UUID

python-ulid has an API for converting between ULID and UUID. As byte sequences they are equivalent, so the conversion cost is 0.

TypeScript / JavaScript

UUIDv7

npm install uuid          # v11+ has native TypeScript and v7 support
import { v7 as uuidv7 } from 'uuid';

const id = uuidv7();
// '0190b3a1-7c4b-7abc-89de-f0123456789a'

From v11 on, the uuid package is fully TypeScript, and v7 is exported. If you need a more full-featured version, there is also the uuidv7 package (by LiosK). It is the reference implementation of Method 2 (Monotonic Random), and its generation-order monotonicity is strict.

npm install uuidv7
import { uuidv7 } from 'uuidv7';

const id = uuidv7();

ULID

The ulid package.

npm install ulid
import { ulid, monotonicFactory } from 'ulid';

ulid();  // '01ARZ3NDEKTSV4RRFFQ69G5FAV'

// When you want to guarantee monotonicity
const monotonic = monotonicFactory();
monotonic();
monotonic();  // always greater than the previous one

It runs in all of browser / Node.js / Deno / Bun.

Rust

UUIDv7

The uuid crate supports it officially. Enable the v7 feature.

# Cargo.toml
[dependencies]
uuid = { version = "1", features = ["v7"] }
use uuid::Uuid;

let id = Uuid::now_v7();
println!("{}", id);
// 0190b3a1-7c4b-7abc-89de-f0123456789a

You can also specify an arbitrary timestamp with Uuid::new_v7(timestamp).

ULID

The ulid crate.

[dependencies]
ulid = "1"
use ulid::Ulid;

let id = Ulid::new();
println!("{}", id);
// 01ARZ3NDEKTSV4RRFFQ69G5FAV

// UUID-compatible conversion
let as_uuid: uuid::Uuid = id.into();

The ulid crate's internal representation is u128, so copying and comparing are fast. Into<Uuid> is also implemented.

6. Using as a Django primary key

6.1 Existing pattern (uuid6 library + UUIDField)

The several Django projects I run were started before Python 3.14 was released, so they use the uuid6 package from PyPI.

# pyproject.toml
dependencies = [
    "uuid6>=2025.0.1",
]
# models.py
from django.db import models
from uuid6 import uuid7


class User(AbstractUser):
    id = models.UUIDField(primary_key=True, default=uuid7, editable=False)
    email = models.EmailField(unique=True)


class LoginCode(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid7, editable=False)
    email = models.EmailField()
    code = models.CharField(max_length=8)

Notes

  • As in default=uuid7, pass the function itself (not a function call uuid7()). Django calls it on every INSERT and generates a new UUID.
  • editable=False removes it from the Django Admin edit form.
  • models.UUIDField is mapped to the uuid type on Postgres and to char(32) on SQLite.

6.2 New pattern (Python 3.14 standard library)

For a project on Python 3.14 or later, you don't even need a dependency library.

import uuid
from django.db import models


class Item(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid7, editable=False)

You can remove the dependency on the uuid6 package. This is recommended for new projects.

6.3 PostgreSQL 18 + Django 5.2 native pattern (the newest)

PostgreSQL 18 (released 2025-09-25) has a built-in uuidv7() function. With Django 5.2's db_default, you can generate the UUID on the DB side, not in the application.

from django.db import models


class UUIDv7(models.Func):
    function = "uuidv7"
    output_field = models.UUIDField()


class UUIDExtractTimestamp(models.Func):
    function = "uuid_extract_timestamp"
    output_field = models.DateTimeField()


class Record(models.Model):
    uuid = models.UUIDField(db_default=UUIDv7(), primary_key=True)
    creation_time = models.GeneratedField(
        expression=UUIDExtractTimestamp("uuid"),
        output_field=models.DateTimeField(),
        db_persist=True,
    )

Generated DDL

CREATE TABLE "items_record" (
    "uuid" uuid DEFAULT (uuidv7()) NOT NULL PRIMARY KEY,
    "creation_time" timestamp GENERATED ALWAYS AS
        (uuid_extract_timestamp("uuid")) STORED
);

Benefits

  • Even when multiple app servers write at the same time, the DB issues the IDs in one place → not affected by clock drift.
  • creation_time is extracted from the UUID automatically, so there is no need to include a separate timestamp column in the INSERT.
  • PostgreSQL 18's Method 3 (sub-ms fraction) strongly guarantees monotonicity within the same session.

6.4 Which to choose

Situation Recommended pattern
Existing project (Python ≤ 3.13) uuid6 package + default=uuid7
New project (Python 3.14+) Standard library uuid.uuid7 + default
Postgres 18 + Django 5.2 decided Native generation with db_default=UUIDv7()
Mainly SQLite/MySQL App-side generation (default=uuid.uuid7)

Right now pattern 6.1 is my mainstay. I plan to adopt 6.3 starting with new projects.

7. Weakness comparison: ULID vs UUIDv7

Weaknesses of ULID

  1. Not an IETF standard. "Handling of the clock going backward across an ms boundary" and "the behavior of monotonic mode" differ subtly from implementation to implementation. Python's python-ulid and JavaScript's ulid do not necessarily behave the same.
  2. No native DB support like PostgreSQL 18's. The choice is to convert it and put it in a UUID type column, or to hold it as BINARY(16) / BYTEA. With the latter you cannot write WHERE id = '01ARZ...', and you end up having to write a conversion function like bytea_to_ulid().
  3. The string looks like just a token at first glance. In code review you get asked "isn't this a secret?" every time.

Weaknesses of UUIDv7

  1. Long as a hex string (36 characters). It looks clumsy when put directly in a URL. For slugs, ULID or a Base62 shortening is better.
  2. Hard to tell apart from UUIDv4 by eye. Looking at the version bits is certain, but people who are not used to it tend to mistake "36 characters with hyphens" for "v4".
  3. The choice of RFC 9562 Method depends on the library. When app A uses Method 1 and app B uses Method 2, the sort order within the same ms may not match. It is not "monotonic because it is UUIDv7" but "monotonic because it is a UUIDv7 whose implementation guarantees monotonicity".
  4. 74 bits of randomness (ULID has 80 bits), so the collision probability is theoretically slightly higher. It is not a problem in practice, though.

8. References

Categories

Archive