A blazing fast, production-grade ULID implementation in Python
Project description
ulid-py
A blazing fast, production-grade ULID implementation in Python. Designed to provide a consistent, ergonomic identifier format, ulid-py is currently used across many of Gaucho Racing's services and projects.
- Lowercase by default — all string output uses lowercase Crockford Base32
- Prefix support — generate entity-scoped IDs like
user_01arz3ndek...ortxn_01arz3ndek... - Distributed uniqueness —
Generatorwith node ID partitioning guarantees collision-free IDs across up to 65,536 nodes without coordination - Monotonic sorting — IDs generated within the same millisecond are strictly ordered
- Fully unrolled encoding — Crockford Base32 encode/decode with no loops
- Thread-safe —
make(),Generator, anddefault_entropy()are safe for concurrent use - 128-bit UUID compatible — drop-in replacement for UUID columns in databases
- Fully typed — PEP 561 compliant with
py.typedmarker, passesmypy --strict - Zero runtime dependencies — only stdlib
Getting Started
Installing
pip install gr-ulid
Usage
import ulid
# Generate a ULID
id = ulid.make()
print(id) # 01jgy5fz7rqv8s3n0x4m6k2w1h
# With a prefix
print(id.prefixed("user")) # user_01jgy5fz7rqv8s3n0x4m6k2w1h
# Parse it back
parsed = ulid.parse("01jgy5fz7rqv8s3n0x4m6k2w1h")
print(parsed.time()) # Unix millisecond timestamp
print(parsed.timestamp()) # datetime
# Parse prefixed IDs
prefix, parsed = ulid.parse_prefixed("user_01jgy5fz7rqv8s3n0x4m6k2w1h")
print(prefix) # "user"
# Use a Generator for distributed systems
gen = ulid.new_generator(
ulid.with_node_id(1),
ulid.with_prefix("evt"),
)
print(gen.make_prefixed()) # evt_01jgy5fz7r...
Specification
This library implements the ULID spec with several opinionated extensions. This section covers the binary format, encoding, monotonicity behavior, distributed uniqueness strategy, and every deviation from the official spec.
Binary Layout
A ULID is 128 bits (16 bytes), stored in big-endian (network byte order) as an immutable bytes object:
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 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Component | Bytes | Bits | Description |
|---|---|---|---|
| Timestamp | [0:6] |
48 | Unix milliseconds, big-endian. Valid until year 10889 AD. |
| Entropy | [6:16] |
80 | Cryptographic randomness (or node-partitioned randomness). |
Using immutable bytes as the underlying type means ULIDs are hashable: they can be used as dictionary keys and set members. Byte comparison ordering is consistent with chronological and lexicographic string ordering because the timestamp occupies the most significant bytes.
Crockford Base32 Encoding
The string representation is 26 characters using the Crockford Base32 alphabet:
0123456789abcdefghjkmnpqrstvwxyz
The first 10 characters encode the 48-bit timestamp, the remaining 16 encode the 80-bit entropy:
ttttttttttrrrrrrrrrrrrrrrr
The encoding and decoding are fully unrolled: every bit extraction/insertion is a single explicit line with no loops. Decoding uses a 256-byte lookup table for O(1) character-to-value conversion, and both upper and lowercase map to the same values, making parsing inherently case-insensitive.
Overflow check: 26 Base32 characters technically encode 130 bits, but a ULID only uses 128. The first character is restricted to values 0–7 (3 bits). Any ULID string starting with 8 or higher is rejected with ErrOverflow. The largest valid ULID is 7zzzzzzzzzzzzzzzzzzzzzzzzz.
parse vs parse_strict
parse skips character validation for speed. Invalid characters (like I, L, O, U) will silently produce wrong bits rather than returning an error. Use parse_strict when accepting untrusted input. Use parse when you control the input (e.g., reading from your own database).
Monotonicity
When multiple ULIDs are generated within the same millisecond, the spec requires monotonic ordering. This library implements monotonicity through MonotonicEntropy:
import os
import ulid
entropy = ulid.monotonic(os.urandom, 0)
ms = ulid.now()
# All three share the same millisecond: entropy is incremented, not re-randomized
id1 = ulid.new(ms, entropy) # random entropy R
id2 = ulid.new(ms, entropy) # R + random_increment
id3 = ulid.new(ms, entropy) # R + random_increment + random_increment
# id1 < id2 < id3 guaranteed
Overflow behavior: The 80-bit entropy space is tracked using a custom _UInt80 type (uint16 high + uint64 low) with explicit masking (since Python integers are arbitrary precision). When incrementing would overflow, ErrMonotonicOverflow is raised. The library never silently wraps around or advances the timestamp.
Thread safety: MonotonicEntropy itself is not thread-safe. For concurrent use, wrap it with LockedMonotonicReader (which adds a threading.Lock), or use default_entropy() / make() which do this automatically. The Generator class also handles its own locking internally.
Entropy Sources
The library accepts any Callable[[int], bytes] as an entropy source:
| Source | Security | Notes |
|---|---|---|
os.urandom |
Cryptographic | Default. Uses OS entropy pool. |
| Custom callable | Varies | Any function (int) -> bytes. |
monotonic(r, inc) |
Inherits from r |
Increments within same ms instead of re-reading. |
None |
None | Zero entropy. Useful for timestamp-only IDs. |
Distributed Uniqueness
For multi-node deployments, the Generator class supports embedding a 16-bit node ID in the first 2 bytes of the entropy field:
gen = ulid.new_generator(ulid.with_node_id(42))
id = gen.make()
This partitions the entropy layout as follows:
Bytes [0:6] - 48-bit timestamp (unchanged)
Bytes [6:8] - 16-bit node ID (0–65535)
Bytes [8:16] - 64-bit monotonic random entropy
Two generators with different node IDs cannot produce the same ULID, even within the same millisecond.
Prefixed IDs
Prefixed IDs are a library extension for entity-scoped identifiers:
id = ulid.make()
id.prefixed("user") # "user_01arz3ndektsv4rrffq69g5fav"
id.prefixed("txn") # "txn_01arz3ndektsv4rrffq69g5fav"
The prefix is not part of the ULID itself. parse_prefixed splits on the first _ and parses the ULID portion:
prefix, id = ulid.parse_prefixed("user_01arz3ndektsv4rrffq69g5fav")
# prefix = "user", id = the parsed ULID
Deviations from the Official Spec
| Behavior | Official Spec | This Library |
|---|---|---|
| String case | Uppercase (01ARZ3NDEK...) |
Lowercase (01arz3ndek...). Parsing remains case-insensitive. |
| Prefixed IDs | Not specified | Supported via prefixed() and parse_prefixed(). |
| Node ID partitioning | Not specified | Supported via Generator with with_node_id(). |
| Excluded letter handling | Crockford spec maps I→1, L→1, O→0 during decoding |
Not mapped. I, L, O, U are treated as invalid in strict mode and produce undefined results in non-strict mode. |
Footguns
parsedoes not validate characters. Useparse_strictfor untrusted input.MonotonicEntropyis not thread-safe. Using it from multiple threads withoutLockedMonotonicReaderwill corrupt state.make()andGeneratorhandle this for you.bytes()andentropy()return the underlying immutable bytes. Since_datais immutablebytes, no copy is needed.Generatorwith node ID clobbers monotonic high bits. If intra-millisecond ordering matters more than distributed uniqueness, usemake()instead.- Monotonic overflow is an error, not a retry. When
ErrMonotonicOverflowis raised, the caller is responsible for handling it.
Benchmarks
Measured with pytest-benchmark on Python 3.14, AMD EPYC 7763 (GitHub Actions CI). Pure Python, no C extensions.
| Operation | Median | Throughput |
|---|---|---|
marshal_binary() |
93 ns | 10.7M ops/sec |
compare() |
122 ns | 8.1M ops/sec |
now() |
201 ns | 4.9M ops/sec |
new() (crypto entropy) |
2.6 µs | 372K ops/sec |
string() |
3.1 µs | 320K ops/sec |
parse() |
3.7 µs | 260K ops/sec |
parse_strict() |
5.0 µs | 198K ops/sec |
make() |
5.5 µs | 180K ops/sec |
new_generator().make() |
5.5 µs | 180K ops/sec |
new_generator().make_prefixed() |
9.3 µs | 106K ops/sec |
Run benchmarks locally:
hatch run bench
API
Constructors
| Function | Description |
|---|---|
make() |
Generate a ULID with current time and default entropy. Thread-safe. |
new(ms, entropy) |
Generate with explicit timestamp and entropy source. |
must_new(ms, entropy) |
Like new (raises on error in Python). |
parse(s) |
Decode a 26-char Base32 string. Case-insensitive. |
parse_strict(s) |
Like parse with character validation. |
parse_prefixed(s) |
Parse a prefix_ulid string, returning (prefix, ULID). |
must_parse(s) |
Like parse (raises on error in Python). |
must_parse_strict(s) |
Like parse_strict (raises on error in Python). |
ULID Methods
| Method | Description |
|---|---|
string() |
26-char lowercase Crockford Base32 string. |
prefixed(p) |
Prefixed string: p_<ulid>. |
bytes() |
Raw 16-byte data. |
time() |
Unix millisecond timestamp. |
timestamp() |
Timestamp as datetime. |
entropy() |
10-byte entropy. |
is_zero() |
True if zero value. |
compare(other) |
Lexicographic comparison (-1, 0, +1). |
set_time(ms) |
Return new ULID with updated timestamp. |
set_entropy(e) |
Return new ULID with updated entropy (10 bytes). |
Python Special Methods
| Method | Description |
|---|---|
__str__ |
Same as string(). |
__bytes__ |
Same as bytes(). |
__int__ |
128-bit integer value. |
__hash__ |
Hashable (usable as dict key / set member). |
__eq__, __lt__, etc. |
Full rich comparison support. |
Serialization
| Method | Description |
|---|---|
marshal_binary() |
Raw 16-byte data. |
marshal_text() |
26-byte ASCII encoded string. |
marshal_json() |
JSON-encoded quoted string. |
ULID.unmarshal_binary(data) |
Parse from 16 bytes. |
ULID.unmarshal_text(data) |
Parse from 26-char string/bytes. |
ULID.unmarshal_json(data) |
Parse from JSON string. |
Time Helpers
| Function | Description |
|---|---|
now() |
Current UTC Unix milliseconds. |
timestamp(dt) |
Convert datetime to Unix ms. |
time(ms) |
Convert Unix ms to datetime. |
max_time() |
Maximum encodable timestamp (year 10889). |
Entropy
| Function | Description |
|---|---|
default_entropy() |
Process-global thread-safe monotonic entropy (os.urandom). |
monotonic(r, inc) |
Create a monotonic entropy source wrapping any callable. |
Generator
| Function/Method | Description |
|---|---|
new_generator(*opts) |
Create a generator with options. |
with_node_id(id) |
Embed a 16-bit node ID for distributed uniqueness. |
with_entropy(r) |
Use a custom entropy source. |
with_prefix(p) |
Set a default prefix. |
gen.make() |
Generate a ULID. Thread-safe. |
gen.make_prefixed(p) |
Generate a prefixed ULID string. |
gen.new(ms) |
Generate with explicit timestamp. |
gen.node_id() |
Get (node_id, has_node). |
Contributing
If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". Don't forget to give the project a star! Thanks again!
- Fork the Project
- Create your Feature Branch (
git checkout -b gh-username/my-amazing-feature) - Commit your Changes (
git commit -m 'Add my amazing feature') - Push to the Branch (
git push origin gh-username/my-amazing-feature) - Open a Pull Request
License
MIT. See LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file gr_ulid-1.1.2.tar.gz.
File metadata
- Download URL: gr_ulid-1.1.2.tar.gz
- Upload date:
- Size: 15.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
802873f0518bd04a99c5df2da7517c60e1664a3d0aa7e2cb3aef4250a33a816d
|
|
| MD5 |
6027449a8b4f4315a7b59e90689eb9a7
|
|
| BLAKE2b-256 |
eeea53d2797cb971b6fed4b3c50802c1c27fde13b6eba4a9bceb916c74ebca7d
|
File details
Details for the file gr_ulid-1.1.2-py3-none-any.whl.
File metadata
- Download URL: gr_ulid-1.1.2-py3-none-any.whl
- Upload date:
- Size: 12.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f59b201de9d834e0f5c2690ef3aed04f807611c3cd0172d615c20c296d667a90
|
|
| MD5 |
2ec94674a9545c4ee5274d5e8059fa73
|
|
| BLAKE2b-256 |
cb733ca978823cb22a9272612cc48ae52d0466afba8c3b0103a90c676d3604ab
|