NULID
Nanosecond-Precision Universally Lexicographically Sortable Identifier
Overview
NULID is a 128-bit identifier with true nanosecond-precision timestamps designed for high-throughput, distributed systems. It combines the simplicity of ULID with sub-millisecond precision for systems that require fine-grained temporal ordering.
True nanosecond precision is achieved using the quanta crate, which provides high-resolution monotonic timing combined with wall-clock synchronization. This ensures proper ordering even on systems where the OS clock only provides microsecond precision.
Why NULID?
The Challenge:
- ULID's 48-bit millisecond timestamp is insufficient for high-throughput systems generating thousands of IDs per millisecond
- Systems processing millions of operations per second need nanosecond precision for proper chronological ordering
The Solution:
- NULID uses a 68-bit nanosecond timestamp for precise chronological ordering
- Maintains 60-bit cryptographically secure randomness for collision resistance
- 128-bit total - same size as UUID, smaller than original 150-bit designs
- 26-character encoding - compact and URL-safe
Features
โจ 128-bit identifier (16 bytes) - UUID-compatible size
โก Blazing fast - 35ns per ID generation
๐ Lexicographically sortable with true nanosecond precision
๐ค 26-character canonical encoding using Crockford's Base32
๐ Extended lifespan - valid until year ~11,326 AD
๐ Memory safe - zero unsafe code, panic-free production paths
๐ URL safe - no special characters
โ๏ธ Monotonic sort order within the same nanosecond
๐ UUID interoperability - seamless conversion to/from UUID
๐ฏ 1.15 quintillion unique IDs per nanosecond (60 bits of randomness)
๐ฏ True nanosecond precision - powered by quanta for high-resolution timing on all platforms
Installation
Add this to your Cargo.toml:
[]
= "0.3"
For UUID interoperability:
[]
= { = "0.3", = ["uuid"] }
Quick Start
Basic Usage
use Nulid;
#
Monotonic Generation
use Generator;
#
SQLx PostgreSQL Support
With the optional sqlx feature, you can store NULIDs directly in PostgreSQL as UUIDs:
use Nulid;
use ;
async
async
This enables:
- Native UUID storage - NULIDs are stored as
PostgreSQLUUID type - Automatic conversion - Seamless encoding/decoding with sqlx
- Time-ordered queries - Query by ID for chronological ordering
- Index efficiency - Use
PostgreSQL's native UUID indexes - Type safety - Compile-time checked queries with sqlx
UUID Interoperability
With the optional uuid feature, you can seamlessly convert between NULID and UUID:
use Nulid;
use Uuid;
// Generate a NULID
let nulid = new?;
// Convert to UUID
let uuid: Uuid = nulid.into;
println!; // "01234567-89ab-cdef-0123-456789abcdef"
// Convert back to NULID
let nulid2: Nulid = uuid.into;
assert_eq!;
// Or use explicit methods
let uuid2 = nulid.to_uuid;
let nulid3 = from_uuid;
This enables:
- Database compatibility - Store as UUID in Postgres,
MySQL, etc. - API compatibility - Accept/return UUIDs while using NULID internally
- Migration path - Gradually migrate from UUID to NULID
- Interoperability - Work with existing UUID-based systems
Sorting
use Nulid;
#
๐ ๏ธ Specification
The NULID is a 128-bit (16 byte) binary identifier composed of:
68-bit timestamp (nanoseconds) 60-bit randomness
|--------------------------------| |--------------------------|
Timestamp Randomness
68 bits 60 bits
Components
Timestamp (68 bits)
- Size: 68-bit integer
- Representation: UNIX time in nanoseconds (ns) since epoch (1970-01-01 00:00:00 UTC)
- Range: 0 to 2^68-1 nanoseconds (~9,356 years from 1970)
- Valid Until: Year ~11,326 AD
- Encoding: Most Significant Bits (MSB) first to ensure lexicographical sortability
Randomness (60 bits)
- Size: 60 bits
- Source: Cryptographically secure randomness via
randcrate with system entropy - Collision Probability: 1.15 ร 10^18 unique values per nanosecond
- Purpose: Ensures uniqueness when multiple IDs are generated within the same nanosecond
Total: 128 bits (16 bytes)
๐ Canonical String Representation
ttttttttttttt rrrrrrrrrrrrr
where:
t= Timestamp (13 characters)r= Randomness (13 characters)
Total Length: 26 characters
Encoding
NULID uses Crockford's Base32 encoding:
0123456789ABCDEFGHJKMNPQRSTVWXYZ
Character Exclusions: The letters I, L, O, and U are excluded to avoid confusion.
Encoding Breakdown
| Component | Bits | Characters | Calculation |
|---|---|---|---|
| Timestamp | 68 | 14 | โ68 รท 5โ |
| Randomness | 60 | 12 | โ60 รท 5โ |
| Total | 128 | 26 | โ128 รท 5โ |
Note: Due to Base32 encoding (5 bits per character), we need 26 characters for 128 bits (130 bits capacity, with 2 bits unused).
๐ข Sorting
NULIDs are lexicographically sortable:
- The timestamp occupies the most significant bits, ensuring time-based sorting
- The randomness provides secondary ordering for IDs within the same nanosecond
- String representation preserves binary sort order
Example Sort Order
01AN4Z07BY79K47PAZ7R9SZK18 โ Earlier
01AN4Z07BY79K47PAZ7R9SZK19
01AN4Z07BY79K47PAZ7R9SZK1A
01AN4Z07BY79K47PAZ7R9SZK1B โ Later
โ๏ธ Monotonicity
The Generator ensures strictly monotonic IDs:
- If the timestamp advances, use new timestamp with fresh random bits
- If the timestamp is the same, increment the previous NULID by 1
- This guarantees strict ordering even when generating millions of IDs per second
Example
use Generator;
#
Overflow
With 60 bits of randomness, you can generate 2^60 (1.15 quintillion) IDs within the same nanosecond before overflow. This is practically impossible in real-world usage.
๐๏ธ Binary Layout and Byte Order
The NULID is encoded as 16 bytes with Most Significant Byte (MSB) first (network byte order / big-endian).
Structure
Byte: 0 1 2 3 4 5 6 7
+-------+-------+-------+-------+-------+-------+-------+-------+
Bits: | Timestamp (68 bits) - nanoseconds since epoch |
+-------+-------+-------+-------+-------+-------+-------+-------+
Byte: 8 9 10 11 12 13 14 15
+-------+-------+-------+-------+-------+-------+-------+-------+
Bits: | T | Randomness (60 bits) |
+-------+-------+-------+-------+-------+-------+-------+-------+
Detailed Layout:
- Bytes 0-7, bits 0-3 of byte 8: 68-bit timestamp (upper 68 bits of u128)
- Bits 4-7 of byte 8, bytes 9-15: 60-bit randomness (lower 60 bits of u128)
This structure ensures:
- โ Natural lexicographic ordering (timestamp in most significant bits)
- โ Simple bit operations (just shift and mask)
- โ Maximum precision (nanosecond resolution)
- โ UUID compatibility (128 bits / 16 bytes)
๐ Comparison: ULID vs NULID
| Feature | ULID | NULID |
|---|---|---|
| Total Bits | 128 | 128 |
| String Length | 26 chars | 26 chars |
| Timestamp Bits | 48 (milliseconds) | 68 (nanoseconds) |
| Randomness Bits | 80 | 60 |
| Time Precision | 1 millisecond | 1 nanosecond |
| Lifespan | Until 10889 AD | Until 11,326 AD |
| IDs per Time Unit | 1.21e+24 / ms | 1.15e+18 / ns |
| Sortable | โ | โ |
| Monotonic | โ | โ |
| URL Safe | โ | โ |
| UUID Compatible | โ | โ |
๐ Performance & Safety
Performance
- 21x faster generation - Reduced from 704ns to 35ns per ID
- 2.8x faster encoding - Optimized Base32 encoding (9.2ns)
- Buffered RNG - Uses
randcrate for amortized cryptographic randomness - Zero-copy operations - Minimal allocations and copies
Safety Guarantees
- Zero unsafe code - Enforced with
#![forbid(unsafe_code)] - Panic-free production paths - All errors handled via
Result - Strict linting - Comprehensive clippy checks for safety
- Memory safe - No buffer overflows, no undefined behavior
- Thread-safe - Concurrent generation without data races
Additional Features
- Optional serde support for serialization (JSON, TOML,
MessagePack, Bincode, etc.)- Binary formats (Bincode,
MessagePack) use efficient 16-byte encoding - Text formats (JSON, TOML) use 26-character string representation
- Binary formats (Bincode,
- Optional UUID interoperability for seamless conversion
- Optional
SQLxsupport forPostgreSQLUUID storage - Thread-safe monotonic generation
- Comprehensive test coverage
- Optimized bit operations
๐ฏ Use Cases
NULID is ideal for:
- High-frequency trading systems requiring nanosecond-level event ordering
- Distributed databases with high write throughput (
PostgreSQLUUID storage via sqlx) - Event sourcing systems where precise ordering is critical
- Microservices architectures generating many concurrent IDs
IoTplatforms processing millions of sensor readings per second- Real-time analytics systems requiring precise event sequencing
PostgreSQLapplications - Store as native UUID with time-based ordering- Migration from UUID - Drop-in replacement with better time ordering
- Any system needing UUID-sized IDs with nanosecond precision and sortability
API Reference
Core Type
;
// UUID conversions (with `uuid` feature)
// Traits
Generator
Error Handling
pub type Result<T> = Result;
๐ฆ Cargo Features
default = ["std"]- Standard library supportstd- Enable standard library features (SystemTime, etc.)serde- Enable serialization/deserialization support (JSON, TOML,MessagePack, Bincode, etc.)uuid- Enable UUID interoperability (conversion to/fromuuid::Uuid)sqlx- EnableSQLxPostgreSQLsupport (stores as UUID, requiresuuidfeature)
Examples:
# With serde (supports JSON, TOML, MessagePack, Bincode, etc.)
[]
= { = "0.2", = ["serde"] }
# With UUID interoperability
[]
= { = "0.2", = ["uuid"] }
# With UUID and serde
[]
= { = "0.2", = ["serde", "uuid"] }
# With SQLx PostgreSQL support
[]
= { = "0.2", = ["sqlx"] }
# All features
[]
= { = "0.2", = ["serde", "uuid", "sqlx"] }
The serde_example demonstrates multiple formats including JSON, MessagePack, TOML, and Bincode:
# Run the serde examples (includes Bincode)
For the sqlx example, see examples/sqlx_postgres.rs:
# Set up PostgreSQL database
# Run the example
๐ Security Considerations
- Cryptographically secure randomness - Uses
randcrate with system entropy for high-quality randomness - Timestamp information is exposed - NULIDs reveal when they were created (down to the nanosecond)
- Not for security purposes - Use proper authentication/authorization mechanisms
- Collision resistance - 60 bits of randomness provides strong collision resistance within the same nanosecond
- Memory safety - Zero unsafe code, preventing memory-related vulnerabilities
๐ ๏ธ Development
Building
Testing
Benchmarks
Results
| Operation | Time | Throughput |
|---|---|---|
| Generate new NULID | 35.03 ns | 28.5M ops/sec |
| From datetime | 14.73 ns | 67.9M ops/sec |
| Monotonic generation | 48.01 ns | 20.8M ops/sec |
| Sequential generation (100 IDs) | 4.78 ยตs | 20.9M IDs/sec |
| Encode to string (array) | 9.18 ns | 109M ops/sec |
| Encode to String (heap) | 33.49 ns | 29.9M ops/sec |
| Decode from string | 8.81 ns | 114M ops/sec |
| Round-trip string | 43.38 ns | 23.1M ops/sec |
| Convert to bytes | 295 ps | 3.39B ops/sec |
| Convert from bytes | 395 ps | 2.53B ops/sec |
| Equality comparison | 2.75 ns | 364M ops/sec |
| Ordering comparison | 2.74 ns | 365M ops/sec |
| Sort 1000 IDs | 13.17 ยตs | 75.9M elem/sec |
| Concurrent (10 threads) | 290 ยตs | 3.45K batch/sec |
| Batch generate 10 | 488 ns | 20.5M elem/sec |
| Batch generate 100 | 4.82 ยตs | 20.8M elem/sec |
| Batch generate 1000 | 48.1 ยตs | 20.8M elem/sec |
Benchmarked on Apple M-series processor with cargo bench
Linting
๐ Background & Evolution
NULID builds upon the excellent ULID specification and addresses:
- โ Millisecond precision limitation of ULID
NULID achieves:
- โ Nanosecond precision for high-throughput systems
- โ 128-bit size (UUID-compatible)
- โ Simple two-part design (timestamp + randomness)
- โ Lexicographic sortability
- โ Compact 26-character encoding
Design Philosophy
- Simplicity - Two parts (timestamp + random) instead of three
- Compatibility - 128 bits like UUID, seamless interoperability
- Precision - Nanosecond timestamps for modern systems
- Performance - Optimized operations (35ns generation, 9ns encoding)
- Safety - Zero unsafe code, panic-free production paths, strict linting
- Reliability - Comprehensive tests, memory-safe by design
๐ License
Licensed under the MIT License. See LICENSE for details.
Built with โก by developers who need nanosecond precision in 128 bits