Differences between ULID and UUIDv7

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

There are two well-known 128-bit time-sortable identifier specifications: ULID and UUIDv7. Both solve the same problem — generating monotonically increasing, mostly-random IDs — but they differ in design philosophy, standardization status, and ecosystem support. This article compares the two, surveys the current library situation in Python, TypeScript, and Rust, and shows concrete patterns for using them as primary keys in Django.

TL;DR

  • 128 bits, with the leading 48 bits encoding Unix time in milliseconds is common to both. String forms are sortable in chronological order.
  • UUIDv7 is an IETF standard (RFC 9562, published May 2024). ULID is a standalone specification on GitHub (2016–). The two diverge clearly in standardization status.
  • 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 Django 5.2 + Python 3.14 + Postgres 18, UUIDv7 primary keys can be implemented without any external libraries.
  • My conclusion: use UUIDv7 as the primary key for any persistent data tied to users. A UUIDv7 string is recognizable as an ID at a glance — and recognizable specifically as v7 — which the 26-character Crockford Base32 string of a ULID is not. ULID is better suited to internal identifiers and URL slugs.

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

Before getting into ULID vs. UUIDv7, it helps to clarify how those two differ from UUIDv4.

UUIDv4 fills 122 bits of the 128 with pure randomness, leaving 6 bits for version and variant.

  • It can generate unique IDs without coordination across distributed systems — the same is true of v7 and ULID.
  • It contains no temporal information, so generation order is unrelated to sort order.
  • For secure tokens you should use something else (e.g. secrets.token_urlsafe()); UUIDv4 is meant to be a non-colliding identifier, not a secret.

The shared motivation behind ULID and UUIDv7 is the need for distributed uniqueness combined with chronologically-sortable strings. Conversely, when sortability is unnecessary and pure randomness is the priority (e.g. unguessable public tokens, randomly distributed shard keys), UUIDv4 may still be the right choice.

Both ULID and UUIDv7 share the following:

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

In other words, they are equivalent in storage; the differences are in encoding format, standardization, and surrounding ecosystem.

2. Historical background

ULID (2016)

  • Author: Alizain Feerasta.
  • Repository: ulid/spec
  • Motivation: address UUIDv4's complaints — long string form, not sortable.
  • Specification: encode 128 bits as 26 characters in Crockford Base32.
  • Standardization status: not an IETF standard, just a specification on GitHub. It was later organized as "ULID Spec v1", but it is not an RFC.
  • Implementations exist in 40+ 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: a reordering of the existing v1 (MAC + time) so that it becomes time-sortable. A backward-compatibility option.
  • v7: takes the ULID-style approach and adopts it into the UUID format. This is the de facto main player.
  • v8: a slot for fully custom implementations (allows application-specific bit layouts).

UUIDv7 is essentially the IETF absorbing the problem statement raised by ULID into a standard. Historically ULID came first; UUIDv7 was standardized later, building on its 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: when multiple ULIDs are generated within the same millisecond, the lowest bits of the random part are incremented by 1 (monotonic mode). This is not a MUST in the spec — it is 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 (used as a sub-ms counter or randomness, depending on the chosen RFC method)
  • 2 bits: variant (10)
  • 62 bits: rand_b

Key difference

UUIDv7 always reserves the version and variant bits as fixed values. That leaves 74 bits available for randomness. ULID uses all 80 of its non-timestamp bits for randomness. The collision probability is slightly lower for ULID, but 74 bits is more than enough in practice.

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

RFC 9562 allows multiple approaches to 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, increment the counter by 1; reset on a new ms.
  • Method 2 (Monotonic Random): within the same ms, treat the previous value's random part as a "randomly seeded counter" and increment it by 1. No fixed counter region — the entire random region is used for monotonic increase.
  • Method 3 (Replace Leftmost Random Bits with Increased Clock Precision): place 12 bits of sub-ms timestamp fraction (1/4096-ms units) into rand_a. PostgreSQL 18 uses Method 3.

This means whether the generation order matches the UUIDv7 string sort order within a single session is implementation-dependent, since libraries pick different methods.

4. String representation differences

ULID

01ARZ3NDEKTSV4RRFFQ69G5FAV
  • Crockford Base32 (5 bits per character × 26 characters = 130 bits; the top 2 bits of the first character must be 0, leaving an effective 128 bits — the first character is restricted to 07).
  • Uppercase only. I, L, O, U are excluded (to avoid misreading and accidental profanity).
  • No hyphens, 26 characters.
  • Visually 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 third group (position 15 with hyphens, position 13 hex-only) is fixed to 7so it is visually obvious that this is a UUIDv7.
  • The first character of the fourth group (position 20 with hyphens, position 17 hex-only) is one of 8/9/a/b — variant 10.

Subjective usability evaluation

UUIDv7 is "more recognizable as an ID by humans". 0190b3a1-7c4b-7abc-... has the unmistakable hyphenation pattern of a UUID, and the leading 7 of the third group identifies it as v7 immediately. It does not feel out of place when shown in API responses or admin panel URLs.

ULID's 01ARZ3NDEKTSV4RRFFQ69G5FAV, on the other hand, is visually indistinguishable from a JWT or random token. When a server response contains one, the first reaction is "wait, is this a secret token I shouldn't be exposing?" This is largely a matter of familiarity, but in code review where you are reading someone else's code, the cognitive load is different.

→ My policy is therefore to use UUIDv7 as the database primary key for anything tied to users.

5. Usage in each language

Python

UUIDv7

Python 3.14+ (released October 2025)

uuid6, uuid7, and uuid8 were added to the standard 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

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 choice.

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())      # represent the same 128 bits as a UUID

python-ulid exposes APIs for converting between ULID and UUID. Since the byte representations are equivalent, the conversion cost is zero.

TypeScript / JavaScript

UUIDv7

npm install uuid          # v11+: native TypeScript, with v7 support
import { v7 as uuidv7 } from 'uuid';

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

The uuid package has been fully ported to TypeScript since v11, and v7 is exported. For something more feature-rich, the uuidv7 package (by LiosK) is a Method 2 (Monotonic Random) reference implementation with strict generation-order monotonicity.

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

const id = uuidv7();

ULID

The ulid package.

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

ulid();  // '01ARZ3NDEKTSV4RRFFQ69G5FAV'

// To guarantee monotonicity
const monotonic = monotonicFactory();
monotonic();
monotonic();  // always greater than the previous value

It runs in browsers, Node.js, Deno, and Bun.

Rust

UUIDv7

The uuid crate has upstream support. 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

Uuid::new_v7(timestamp) lets you specify an arbitrary 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 uses a u128 internally, 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)

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

  • default=uuid7 passes the function itself (not a call uuid7()). Django invokes it on each INSERT to generate a new UUID.
  • editable=False removes the field from the Django Admin edit form.
  • models.UUIDField maps to uuid on Postgres and char(32) on SQLite.

6.2 New pattern (Python 3.14 standard library)

For projects on Python 3.14 or later, no third-party dependency is needed.

import uuid
from django.db import models


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

The uuid6 dependency can be dropped. This is the recommended pattern for new projects.

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

PostgreSQL 18 (released 2025-09-25) ships a built-in uuidv7() function. With Django 5.2's db_default, the database — not the application — generates the UUID.

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 application servers write concurrently, the database issues IDs centrally — clock drift between app servers is no longer a concern.
  • creation_time is auto-extracted from the UUID; no need to include a separate timestamp column in the INSERT.
  • PostgreSQL 18's Method 3 (sub-ms fraction) provides strong monotonicity guarantees 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 confirmed db_default=UUIDv7() (DB-side generation)
SQLite/MySQL primarily App-side generation (default=uuid.uuid7)

In my own setup, 6.1 is currently the dominant pattern. New projects will move toward 6.3.

7. Weakness comparison: ULID vs UUIDv7

Weaknesses of ULID

  1. Not an IETF standard. Implementations differ subtly on "what happens when the clock goes backward across an ms boundary" and "the exact behavior of monotonic mode". Python's python-ulid and JavaScript's ulid may not behave identically.
  2. No native database support like PostgreSQL 18's. You either convert to a UUID typed column, or store as BINARY(16) / BYTEA. The latter forbids WHERE id = '01ARZ...' queries and forces you to write a bytea_to_ulid() helper.
  3. The string looks like just another opaque token. In code review, you end up confirming "this isn't a secret, right?" repeatedly.

Weaknesses of UUIDv7

  1. The hex string is long (36 characters). Awkward when placed directly in a URL. For a slug, a ULID or Base62 abbreviation is better.
  2. Visually hard to distinguish from UUIDv4. The version bits are conclusive, but if you're not used to it, "36 characters with hyphens" reads as "v4".
  3. Method choice is library-dependent (RFC 9562). If app A uses Method 1 and app B uses Method 2, sort order within the same ms may not match. "It is a UUIDv7, therefore monotonic" is wrong; the right framing is "it is a UUIDv7 from an implementation that guarantees monotonicity".
  4. 74 bits of randomness (vs. 80 for ULID), so collision probability is theoretically slightly higher. Not a practical concern.

8. References

Please rate this article (No signup or login required)
Currently unrated
The author runs the application development company Cyberneura.
We look forward to discussing your development needs.

Categories

Archive