nulid 0.5.0

Nanosecond-Precision Universally Lexicographically Sortable Identifier
Documentation

NULID

Nanosecond-Precision Universally Lexicographically Sortable Identifier

Crates.io Documentation License Rust Version


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:

[dependencies]
nulid = "0.4"

With optional features:

[dependencies]
nulid = { version = "0.4", features = ["uuid"] }        # UUID conversion
nulid = { version = "0.4", features = ["derive"] }      # Id derive macro
nulid = { version = "0.4", features = ["macros"] }      # nulid!() macro
nulid = { version = "0.4", features = ["serde"] }       # Serialization
nulid = { version = "0.4", features = ["sqlx"] }        # PostgreSQL support
nulid = { version = "0.4", features = ["postgres-types"] } # PostgreSQL types
nulid = { version = "0.4", features = ["rkyv"] }        # Zero-copy serialization

# For derive macro, also add:
nulid_derive = "0.4"

Quick Start

Basic Usage

use nulid::Nulid;

# fn main() -> nulid::Result<()> {
// Generate a new NULID
let id = Nulid::new()?;
println!("{}", id); // "01AN4Z07BY79K47PAZ7R9SZK18"

// Parse from string
let parsed: Nulid = "01AN4Z07BY79K47PAZ7R9SZK18".parse()?;

// Extract components
let nanos = id.nanos();    // u128: nanoseconds since epoch
let micros = id.micros();  // u128: microseconds since epoch
let millis = id.millis();  // u128: milliseconds since epoch
let random = id.random();  // u64: 60-bit random value
# Ok(())
# }

Convenient Generation with nulid!() Macro

With the macros feature:

use nulid::nulid;

// Simple generation (panics on error)
let id = nulid!();

// With error handling
fn example() -> Result<(), Box<dyn std::error::Error>> {
    let id = nulid!(?)?;
    Ok(())
}

// Multiple IDs
let (id1, id2, id3) = (nulid!(), nulid!(), nulid!());

Type-Safe ID Wrappers with Id Derive

With the derive feature:

use nulid::Nulid;
use nulid_derive::Id;

#[derive(Id, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct UserId(Nulid);

#[derive(Id, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct OrderId(Nulid);

fn example() -> Result<(), Box<dyn std::error::Error>> {
    // Type-safe IDs that can't be mixed
    let user_id = UserId::from(nulid::Nulid::new()?);
    let order_id = OrderId::from(nulid::Nulid::new()?);

    // Parse from strings
    let user_id: UserId = "01H0JQ4VEFSBV974PRXXWEK5ZW".parse()?;

    // Display, FromStr, TryFrom, AsRef all implemented automatically
    println!("{}", user_id);
    Ok(())
}

Conversions and Traits

use nulid::Nulid;

# fn main() -> nulid::Result<()> {
let id = Nulid::new()?;

// Convert to/from bytes
let bytes = id.to_bytes();          // [u8; 16]
let id2 = Nulid::from_bytes(bytes);

// Ergonomic conversions using standard traits
let id3: Nulid = bytes.into();      // From<[u8; 16]>
let bytes2: [u8; 16] = id.into();   // Into<[u8; 16]>
let value: u128 = id.into();        // Into<u128>
let id4: Nulid = value.into();      // From<u128>

// Safe conversion from byte slices
let slice: &[u8] = &bytes;
let id5 = Nulid::try_from(slice)?;  // TryFrom<&[u8]>
# Ok(())
# }

Monotonic Generation

use nulid::Generator;

# fn main() -> nulid::Result<()> {
let generator = Generator::new();

// Generate multiple IDs - guaranteed strictly increasing
let id1 = generator.generate()?;
let id2 = generator.generate()?;
let id3 = generator.generate()?;

assert!(id1 < id2);
assert!(id2 < id3);
# Ok(())
# }

SQLx PostgreSQL Support

With the optional sqlx feature, you can store NULIDs directly in PostgreSQL as UUIDs:

use nulid::Nulid;
use sqlx::{PgPool, Row};

#[derive(sqlx::FromRow)]
struct User {
    id: Nulid,
    name: String,
}

async fn insert_user(pool: &PgPool, id: Nulid, name: &str) -> sqlx::Result<()> {
    sqlx::query("INSERT INTO users (id, name) VALUES ($1, $2)")
        .bind(id)  // Automatically converts to UUID
        .bind(name)
        .execute(pool)
        .await?;
    Ok(())
}

async fn get_user(pool: &PgPool, id: Nulid) -> sqlx::Result<User> {
    sqlx::query_as::<_, User>("SELECT id, name FROM users WHERE id = $1")
        .bind(id)
        .fetch_one(pool)
        .await
}

This enables:

  • Native UUID storage - NULIDs are stored as PostgreSQL UUID 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::Nulid;
use uuid::Uuid;

// Generate a NULID
let nulid = Nulid::new()?;

// Convert to UUID
let uuid: Uuid = nulid.into();
println!("UUID: {}", uuid); // "01234567-89ab-cdef-0123-456789abcdef"

// Convert back to NULID
let nulid2: Nulid = uuid.into();
assert_eq!(nulid, nulid2);

// Or use explicit methods
let uuid2 = nulid.to_uuid();
let nulid3 = Nulid::from_uuid(uuid2);

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::Nulid;

# fn main() -> nulid::Result<()> {
let mut ids = vec![
    Nulid::new()?,
    Nulid::new()?,
    Nulid::new()?,
];

// NULIDs are naturally sortable by timestamp
ids.sort();

// Verify chronological order
assert!(ids.windows(2).all(|w| w[0] < w[1]));
# Ok(())
# }

๐Ÿ› ๏ธ 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 rand crate 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:

  1. If the timestamp advances, use new timestamp with fresh random bits
  2. If the timestamp is the same, increment the previous NULID by 1
  3. This guarantees strict ordering even when generating millions of IDs per second

Example

use nulid::Generator;

# fn main() -> nulid::Result<()> {
let generator = Generator::new();

// Even if called within the same nanosecond
let id1 = generator.generate()?; // ...XYZ
let id2 = generator.generate()?; // ...XYZ + 1
let id3 = generator.generate()?; // ...XYZ + 2

assert!(id1 < id2 && id2 < id3);
# Ok(())
# }

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 rand crate 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
  • Optional UUID interoperability for seamless conversion
  • Optional SQLx support for PostgreSQL UUID 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 (PostgreSQL UUID storage via sqlx)
  • Event sourcing systems where precise ordering is critical
  • Microservices architectures generating many concurrent IDs
  • IoT platforms processing millions of sensor readings per second
  • Real-time analytics systems requiring precise event sequencing
  • PostgreSQL applications - 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

pub struct Nulid(u128);

impl Nulid {
    // Generation
    pub fn new() -> Result<Self>;
    pub fn now() -> Result<Self>;

    // Construction
    pub const fn from_nanos(timestamp_nanos: u128, rand: u64) -> Self;
    pub const fn from_u128(value: u128) -> Self;
    pub const fn from_bytes(bytes: [u8; 16]) -> Self;
    pub fn from_str(s: &str) -> Result<Self>;

    // Extraction
    pub const fn nanos(self) -> u128;                    // Nanoseconds
    pub const fn micros(self) -> u128;                   // Microseconds
    pub const fn millis(self) -> u128;                   // Milliseconds
    pub const fn random(self) -> u64;
    pub const fn parts(self) -> (u128, u64);

    // Conversion
    pub const fn as_u128(self) -> u128;
    pub const fn to_bytes(self) -> [u8; 16];
    pub fn encode(self, buf: &mut [u8; 26]);

    // UUID interoperability (with `uuid` feature)
    #[cfg(feature = "uuid")]
    pub fn to_uuid(self) -> uuid::Uuid;
    #[cfg(feature = "uuid")]
    pub fn from_uuid(uuid: uuid::Uuid) -> Self;

    // Time utilities
    pub fn datetime(self) -> SystemTime;
    pub fn duration_since_epoch(self) -> Duration;

    // Utilities
    pub const fn nil() -> Self;
    pub const fn is_nil(self) -> bool;

    // Constants
    pub const MIN: Self;
    pub const MAX: Self;
    pub const ZERO: Self;
}

// Standard trait implementations for ergonomic conversions
impl From<u128> for Nulid { }
impl From<Nulid> for u128 { }
impl From<[u8; 16]> for Nulid { }
impl From<Nulid> for [u8; 16] { }
impl AsRef<u128> for Nulid { }
impl TryFrom<&[u8]> for Nulid { }

// UUID conversions (with `uuid` feature)
#[cfg(feature = "uuid")]
impl From<uuid::Uuid> for Nulid { }
#[cfg(feature = "uuid")]
impl From<Nulid> for uuid::Uuid { }

// Standard traits
impl Display for Nulid { }
impl FromStr for Nulid { }
impl Ord for Nulid { }
impl Default for Nulid { }  // Returns Nulid::ZERO

Generator

pub struct Generator { }

impl Generator {
    pub const fn new() -> Self;
    pub fn generate(&self) -> Result<Nulid>;
    pub fn reset(&self);
    pub fn last(&self) -> Option<Nulid>;
}

Error Handling

pub enum Error {
    RandomError,
    InvalidChar(char, usize),
    InvalidLength { expected: usize, found: usize },
    MutexPoisoned,
}

pub type Result<T> = std::result::Result<T, Error>;

๐Ÿ“ฆ Cargo Features

  • default = ["std"] - Standard library support
  • std - Enable standard library features (SystemTime, etc.)
  • derive - Enable Id derive macro for type-safe wrapper types (requires nulid_derive)
  • macros - Enable nulid!() macro for convenient generation (requires nulid_macros)
  • serde - Enable serialization/deserialization support (JSON, TOML, MessagePack, Bincode, etc.)
  • uuid - Enable UUID interoperability (conversion to/from uuid::Uuid)
  • sqlx - Enable SQLx PostgreSQL support (stores as UUID, requires uuid feature)
  • postgres-types - Enable PostgreSQL postgres-types crate support
  • rkyv - Enable zero-copy serialization support

Examples:

# With serde (supports JSON, TOML, MessagePack, Bincode, etc.)
[dependencies]
nulid = { version = "0.4", features = ["serde"] }

# With UUID interoperability
[dependencies]
nulid = { version = "0.4", features = ["uuid"] }

# With derive macro for type-safe IDs
[dependencies]
nulid = { version = "0.4", features = ["derive"] }
nulid_derive = "0.4"

# With convenient nulid!() macro
[dependencies]
nulid = { version = "0.4", features = ["macros"] }

# With both derive and macros
[dependencies]
nulid = { version = "0.4", features = ["derive", "macros"] }
nulid_derive = "0.4"

# With SQLx PostgreSQL support
[dependencies]
nulid = { version = "0.4", features = ["sqlx"] }

# All features
[dependencies]
nulid = { version = "0.4", features = ["derive", "macros", "serde", "uuid", "sqlx", "postgres-types", "rkyv"] }
nulid_derive = "0.4"

The serde_example demonstrates multiple formats including JSON, MessagePack, TOML, and Bincode:

# Run the serde examples (includes Bincode)
cargo run --example serde_example --features serde

For the sqlx example, see examples/sqlx_postgres.rs:

# Set up PostgreSQL database
export DATABASE_URL="postgresql://localhost/nulid_example"
createdb nulid_example

# Run the example
cargo run --example sqlx_postgres --features sqlx

๐Ÿ”’ Security Considerations

  1. Cryptographically secure randomness - Uses rand crate with system entropy for high-quality randomness
  2. Timestamp information is exposed - NULIDs reveal when they were created (down to the nanosecond)
  3. Not for security purposes - Use proper authentication/authorization mechanisms
  4. Collision resistance - 60 bits of randomness provides strong collision resistance within the same nanosecond
  5. Memory safety - Zero unsafe code, preventing memory-related vulnerabilities

๐Ÿ› ๏ธ Development

Building

cargo build --release

Testing

cargo test

Benchmarks

cargo bench

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

cargo clippy -- -D warnings

๐Ÿ“š 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

  1. Simplicity - Two parts (timestamp + random) instead of three
  2. Compatibility - 128 bits like UUID, seamless interoperability
  3. Precision - Nanosecond timestamps for modern systems
  4. Performance - Optimized operations (35ns generation, 9ns encoding)
  5. Safety - Zero unsafe code, panic-free production paths, strict linting
  6. 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