
NULID
Nanosecond-Precision Universally Lexicographically Sortable Identifier
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
High-performance - 11.78ns 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.8"
With optional features:
[]
= { = "0.8", = ["uuid"] } # UUID conversion
= { = "0.8", = ["derive"] } # Id derive macro
= { = "0.8", = ["macros"] } # nulid!() macro
= { = "0.8", = ["serde"] } # Serialization
= { = "0.8", = ["sqlx"] } # PostgreSQL support
= { = "0.8", = ["postgres-types"] } # PostgreSQL types
= { = "0.8", = ["rkyv"] } # Zero-copy serialization
= { = "0.8", = ["chrono"] } # DateTime<Utc> support
= { = "0.8", = ["jiff"] } # Timestamp support
Quick Start
Basic Usage
use Nulid;
#
Convenient Generation with nulid!() Macro
With the macros feature:
#
#
#
#
Type-Safe ID Wrappers with Id Derive
With the derive feature:
#
#
#
#
Conversions and Traits
use Nulid;
#
Monotonic Generation
use Generator;
#
Distributed Generation (Multi-Node)
For distributed systems requiring guaranteed cross-node uniqueness:
use ;
#
Testing with Mock Clock
The generator supports dependency injection for testing clock skew scenarios:
use ;
use Duration;
#
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:
#
#
#
#
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
Chrono DateTime Support
With the optional chrono feature, you can convert between NULIDs and chrono::DateTime<Utc>:
#
#
#
#
Works with derived Id types too (with derive and chrono features):
use ;
use ;
;
let user_id = new?;
let created_at = user_id.chrono_datetime?;
let dt = Utc.with_ymd_and_hms.unwrap;
let user_id = from_chrono_datetime?;
This enables:
- Human-readable timestamps - Convert NULID timestamps to standard
DateTimeformat - Time-based queries - Easy integration with chrono-based time operations
- Nanosecond precision - Full nanosecond precision is preserved
- Bidirectional conversion - Create NULIDs from
DateTimeor extractDateTimefrom NULIDs - Timezone support - Uses
DateTimein UTC for consistency
Jiff Timestamp Support
With the optional jiff feature, you can convert between NULIDs and jiff::Timestamp:
#
#
#
#
Works with derived Id types too (with derive and jiff features):
use ;
use Timestamp;
;
let user_id = new?;
let ts = user_id.jiff_timestamp?;
let ts = from_second.expect;
let user_id = from_jiff_timestamp?;
This enables:
- Modern time library - Uses jiff, a well-designed date-time library inspired by Temporal
- Nanosecond precision - Full nanosecond precision is preserved
- Bidirectional conversion - Create NULIDs from
Timestampor extractTimestampfrom NULIDs - Easy arithmetic - jiff provides convenient duration arithmetic
- Timezone support - Full timezone-aware datetime support
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
- High-performance generation - 11.78ns per ID
- Optimized Base32 encoding - 9.1ns
- 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
Command-Line Interface
The nulid binary provides a powerful CLI for working with NULIDs.
Installation
Install the CLI with all features enabled:
Or build from source:
Usage
# Generate NULIDs
# Inspect NULID details
# Output shows: timestamp, random bits, bytes, datetime, UUID (if feature enabled)
# Parse and validate
# Compare two NULIDs
# Shows which is earlier and time difference in nanoseconds
# Sort NULIDs chronologically
|
# Decode to hex
UUID Commands (requires --features uuid)
# Convert NULID to UUID
# Convert UUID to NULID
DateTime Commands (requires --features chrono)
# Convert NULID to ISO 8601 datetime
# Output: 2024-01-01T00:00:00.123456789+00:00
# Create NULID from datetime
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
;
// Standard trait implementations for ergonomic conversions
// UUID conversions (with `uuid` feature)
// Standard traits
// Returns Nulid::ZERO
Generator
// Unified generator with injectable dependencies
// Type aliases
pub type DefaultGenerator = ;
pub type DistributedGenerator = ;
Clock and RNG Traits (for testing)
// Clock abstraction
; // Production: uses quanta
; // Testing: controllable time
// RNG abstraction
; // Production: cryptographic RNG
; // Testing: reproducible sequences
; // Debugging: 0, 1, 2, 3...
// Node ID abstraction
; // Default: 60 bits random (ZST, zero overhead)
; // Distributed: 16 bits node + 44 bits random
Error Handling
pub type Result<T> = Result;
Cargo Features
default = ["std"]- Standard library supportstd- Enable standard library features (SystemTime, etc.)derive- EnableIdderive macro for type-safe wrapper types (requiresnulid_derive)macros- Enablenulid!()macro for convenient generation (requiresnulid_macros)serde- Enable serialization/deserialization support (JSON, TOML,MessagePack, Bincode, etc.)uuid- Enable UUID interoperability (conversion to/fromuuid::Uuid)sqlx- EnableSQLxPostgreSQLsupport (stores as UUID, requiresuuidfeature)postgres-types- EnablePostgreSQLpostgres-typescrate supportrkyv- Enable zero-copy serialization supportchrono- Enablechrono::DateTime<Utc>conversion supportjiff- Enablejiff::Timestampconversion support
Examples:
# With serde (supports JSON, TOML, MessagePack, Bincode, etc.)
[]
= { = "0.8", = ["serde"] }
# With UUID interoperability
[]
= { = "0.8", = ["uuid"] }
# With derive macro for type-safe IDs
[]
= { = "0.8", = ["derive"] }
= "0.8"
# With convenient nulid!() macro
[]
= { = "0.8", = ["macros"] }
# With both derive and macros
[]
= { = "0.8", = ["derive", "macros"] }
= "0.8"
# With SQLx PostgreSQL support
[]
= { = "0.8", = ["sqlx"] }
# With chrono DateTime support
[]
= { = "0.8", = ["chrono"] }
# With jiff Timestamp support
[]
= { = "0.8", = ["jiff"] }
# All features
[]
= { = "0.8", = ["derive", "macros", "serde", "uuid", "sqlx", "postgres-types", "rkyv", "chrono", "jiff"] }
= "0.8"
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 | 11.78 ns | 84.9M ops/sec |
| From datetime | 14.11 ns | 70.9M ops/sec |
| Monotonic generation | 20.96 ns | 47.7M ops/sec |
| Sequential generation (100 IDs) | 2.10 µs | 47.5M IDs/sec |
| Encode to string (array) | 9.10 ns | 110M ops/sec |
| Encode to String (heap) | 32.84 ns | 30.5M ops/sec |
| Decode from string | 8.87 ns | 113M ops/sec |
| Round-trip string | 42.04 ns | 23.8M ops/sec |
| Convert to bytes | 293.75 ps | 3.40B ops/sec |
| Convert from bytes | 392.82 ps | 2.55B ops/sec |
| Equality comparison | 2.80 ns | 357M ops/sec |
| Ordering comparison | 2.82 ns | 355M ops/sec |
| Sort 1000 IDs | 13.02 µs | 76.8M elem/sec |
| Concurrent (10 threads) | 183.60 µs | 5.45K batch/sec |
| Batch generate 10 | 234.25 ns | 42.7M elem/sec |
| Batch generate 100 | 2.23 µs | 44.7M elem/sec |
| Batch generate 1000 | 21.53 µs | 46.4M elem/sec |
Benchmarked on Apple M2 Pro 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 for performance (11.78ns generation, 9.1ns 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