---
slug: "ulid-vs-uuidv7-django-primary-key"
title: "Differences between ULID and UUIDv7"
description: "A detailed comparison of ULID and UUIDv7 — two 128-bit time-sortable identifier specs. Covers RFC 9562 bit layouts, standardization status, current libraries in Python, TypeScript, and Rust, and concrete patterns for using UUIDv7 as a Django primary key with Django 5.2, Python 3.14, and PostgreSQL 18."
url: "https://www.ytyng.com/en/blog/ulid-vs-uuidv7-django-primary-key"
publish_date: "2026-05-09T03:00:26.279Z"
created: "2026-05-09T03:00:26.287Z"
updated: "2026-05-09T03:15:12.613Z"
categories: []
keywords: ""
featured_image_url: "https://media.ytyng.com/resize/20260509/a9fbe34b32bd4dac8a8f14f11d4829e9.png.webp?width=768"
has_video: false
has_music: false
video_urls: []
music_urls: []
lang: "en"
---

# Differences between ULID and UUIDv7

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`](https://github.com/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](https://www.rfc-editor.org/rfc/rfc9562.html), 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 `0`–`7`).
- 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 `7` — **so 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.

```python
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.

```bash
pip install uuid6
```

```python
from uuid6 import uuid7

new_id = uuid7()
```

#### ULID

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

```bash
pip install python-ulid
```

```python
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

```bash
npm install uuid          # v11+: native TypeScript, with v7 support
```

```ts
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.

```bash
npm install uuidv7
```

```ts
import { uuidv7 } from 'uuidv7';

const id = uuidv7();
```

#### ULID

The `ulid` package.

```bash
npm install ulid
```

```ts
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.

```toml
# Cargo.toml
[dependencies]
uuid = { version = "1", features = ["v7"] }
```

```rust
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.

```toml
[dependencies]
ulid = "1"
```

```rust
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.

```toml
# pyproject.toml
dependencies = [
    "uuid6>=2025.0.1",
]
```

```python
# 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.

```python
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.

```python
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

```sql
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

- [RFC 9562: Universally Unique IDentifiers (UUIDs)](https://www.rfc-editor.org/rfc/rfc9562.html)
- [ULID Specification (ulid/spec)](https://github.com/ulid/spec)
- [Python 3.14 uuid module documentation](https://docs.python.org/3/library/uuid.html)
- [PostgreSQL 18 Release Notes](https://www.postgresql.org/docs/current/release-18.html)
- [How to use UUIDv7 in Python, Django and PostgreSQL — Paolo Melchiorre](https://www.paulox.net/2025/11/14/how-to-use-uuidv7-in-python-django-and-postgresql/)
- [UUIDv7 Comes to PostgreSQL 18 — Nile](https://www.thenile.dev/blog/uuidv7)
- [npm: uuid](https://www.npmjs.com/package/uuid) / [npm: uuidv7](https://www.npmjs.com/package/uuidv7) / [npm: ulid](https://www.npmjs.com/package/ulid)
- [crates.io: uuid](https://crates.io/crates/uuid) / [crates.io: ulid](https://crates.io/crates/ulid)
- [PyPI: uuid6](https://pypi.org/project/uuid6/) / [PyPI: python-ulid](https://pypi.org/project/python-ulid/)
