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
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.6"
With optional features:
[]
= { = "0.6", = ["uuid"] } # UUID conversion
= { = "0.6", = ["derive"] } # Id derive macro
= { = "0.6", = ["macros"] } # nulid!() macro
= { = "0.6", = ["serde"] } # Serialization
= { = "0.6", = ["sqlx"] } # PostgreSQL support
= { = "0.6", = ["postgres-types"] } # PostgreSQL types
= { = "0.6", = ["rkyv"] } # Zero-copy serialization
= { = "0.6", = ["chrono"] } # DateTime<Utc> support
Quick Start
Basic Usage
use Nulid;
#
Convenient Generation with nulid!() Macro
With the macros feature:
use nulid;
// Simple generation (panics on error)
let id = nulid!;
// With error handling
// Multiple IDs
let = ;
Type-Safe ID Wrappers with Id Derive
With the derive feature:
use Nulid;
use Id;
;
;
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:
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
Chrono DateTime Support
With the optional chrono feature, you can convert between NULIDs and chrono::DateTime<Utc>:
use Nulid;
use ;
// Generate a NULID
let id = new?;
// Convert to DateTime<Utc>
let dt: = id.chrono_datetime;
println!; // "2025-12-23 10:30:45.123456789 UTC"
// Create NULID from DateTime<Utc>
let dt = Utc.with_ymd_and_hms.unwrap;
let id = from_chrono_datetime?;
// Works with derived Id types too
;
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
Protocol Buffers Support
With the optional proto feature, you can serialize NULIDs to Protocol Buffers format:
use Nulid;
use Nulid as ProtoNulid;
use Message;
// Generate a NULID
let nulid = new?;
// Convert to protobuf message
let proto = nulid.to_proto;
println!;
println!;
// Encode to bytes
let encoded = proto.encode_to_vec;
// Decode from bytes
let decoded = decode?;
// Convert back to NULID
let nulid2 = from_proto;
assert_eq!;
// Using From trait
let proto2: ProtoNulid = nulid.into;
let nulid3: Nulid = proto2.into;
This enables:
- Cross-language compatibility - Use NULIDs in any language that supports Protocol Buffers
- Efficient serialization - Binary format is compact (2 × uint64 fields)
- Type safety - Strongly-typed protobuf messages prevent serialization errors
- All 128 bits preserved - Full precision maintained through high/low uint64 split
- Standards compliance - Uses buf lint for protobuf best practices
Using Nulid in Your Own Proto Files
To use the Nulid message type in your own protobuf definitions, you can import the nulid proto file:
syntax = "proto3";
package myapp.v1;
import "nulid/v1/nulid.proto";
message User {
nulid.v1.Nulid id = 1;
string name = 2;
string email = 3;
nulid.v1.Nulid created_at = 4;
}
message Order {
nulid.v1.Nulid id = 1;
nulid.v1.Nulid user_id = 2;
repeated nulid.v1.Nulid item_ids = 3;
}
Setup for prost-build:
In your build.rs, configure the import path to find the nulid proto files:
Alternative: Copy the proto file
If you prefer to vendor the proto definition, copy proto/nulid/v1/nulid.proto from the nulid repository into your project's proto directory:
# In your project
Then use it in your proto files with the same import statement shown above.
Converting between Rust types:
use Nulid;
use Nulid as ProtoNulid;
// Your generated message
let user = User ;
// Convert proto Nulid back to Rust Nulid
if let Some = user.id
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: 12 bits node + 48 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 supportproto- Enable Protocol Buffers (protobuf) support for serialization
Examples:
# With serde (supports JSON, TOML, MessagePack, Bincode, etc.)
[]
= { = "0.6", = ["serde"] }
# With UUID interoperability
[]
= { = "0.6", = ["uuid"] }
# With derive macro for type-safe IDs
[]
= { = "0.6", = ["derive"] }
= "0.6"
# With convenient nulid!() macro
[]
= { = "0.6", = ["macros"] }
# With both derive and macros
[]
= { = "0.6", = ["derive", "macros"] }
= "0.6"
# With SQLx PostgreSQL support
[]
= { = "0.6", = ["sqlx"] }
# With chrono DateTime support
[]
= { = "0.6", = ["chrono"] }
# With Protocol Buffers support
[]
= { = "0.6", = ["proto"] }
# All features
[]
= { = "0.6", = ["derive", "macros", "serde", "uuid", "sqlx", "postgres-types", "rkyv", "chrono", "proto"] }
= "0.6"
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
For the proto example, see examples/proto_example.rs:
# Run the protobuf 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