nulid 0.5.11

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

[dependencies]
nulid = "0.5"

With optional features:

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

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

Chrono DateTime Support

With the optional chrono feature, you can convert between NULIDs and chrono::DateTime<Utc>:

use nulid::Nulid;
use chrono::{DateTime, Utc, TimeZone};

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

// Convert to DateTime<Utc>
let dt: DateTime<Utc> = id.chrono_datetime();
println!("Timestamp: {}", dt); // "2025-12-23 10:30:45.123456789 UTC"

// Create NULID from DateTime<Utc>
let dt = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
let id = Nulid::from_chrono_datetime(dt)?;

// Works with derived Id types too
#[derive(Id)]
struct UserId(Nulid);

let user_id = UserId::new()?;
let created_at = user_id.chrono_datetime();

let dt = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
let user_id = UserId::from_chrono_datetime(dt)?;

This enables:

  • Human-readable timestamps - Convert NULID timestamps to standard DateTime format
  • Time-based queries - Easy integration with chrono-based time operations
  • Nanosecond precision - Full nanosecond precision is preserved
  • Bidirectional conversion - Create NULIDs from DateTime or extract DateTime from NULIDs
  • Timezone support - Uses DateTime in UTC for consistency

Protocol Buffers Support

With the optional proto feature, you can serialize NULIDs to Protocol Buffers format:

use nulid::Nulid;
use nulid::proto::nulid::Nulid as ProtoNulid;
use prost::Message;

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

// Convert to protobuf message
let proto = nulid.to_proto();
println!("High bits: 0x{:016x}", proto.high);
println!("Low bits:  0x{:016x}", proto.low);

// Encode to bytes
let encoded = proto.encode_to_vec();

// Decode from bytes
let decoded = ProtoNulid::decode(&*encoded)?;

// Convert back to NULID
let nulid2 = Nulid::from_proto(decoded);
assert_eq!(nulid, nulid2);

// 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:

fn main() {
    // Get the nulid source directory from the dependency
    let nulid_proto_dir = std::env::var("DEP_NULID_PROTO_DIR")
        .unwrap_or_else(|_| {
            // Fallback: use the path from your nulid dependency
            format!("{}/proto", env!("CARGO_MANIFEST_DIR"))
        });

    prost_build::Config::new()
        .compile_protos(
            &["proto/myapp/v1/user.proto"],
            &["proto/", &nulid_proto_dir],  // Include both your proto dir and nulid's
        )
        .expect("Failed to compile protobuf");
}

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
mkdir -p proto/nulid/v1
curl -o proto/nulid/v1/nulid.proto \
  https://raw.githubusercontent.com/kakilangit/nulid/main/proto/nulid/v1/nulid.proto

Then use it in your proto files with the same import statement shown above.

Converting between Rust types:

use nulid::Nulid;
use nulid::proto::nulid::Nulid as ProtoNulid;

// Your generated message
let user = myapp::v1::User {
    id: Some(Nulid::new()?.to_proto()),
    name: "Alice".to_string(),
    email: "alice@example.com".to_string(),
    created_at: Some(Nulid::new()?.to_proto()),
};

// Convert proto Nulid back to Rust Nulid
if let Some(proto_id) = user.id {
    let rust_id = Nulid::from_proto(proto_id);
    println!("User ID: {}", rust_id);
}

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

  • High-performance generation - 11.78ns per ID
  • Optimized Base32 encoding - 9.1ns
  • 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

Command-Line Interface

The nulid binary provides a powerful CLI for working with NULIDs.

Installation

Install the CLI with all features enabled:

cargo install nulid --all-features

Or build from source:

cargo build --bin nulid --release --features "uuid,chrono"

Usage

# Generate NULIDs
nulid generate      # Generate one NULID
nulid gen 10        # Generate 10 NULIDs

# Inspect NULID details
nulid inspect 01GZWQ22K2MNDR0GAQTE834QRV
# Output shows: timestamp, random bits, bytes, datetime, UUID (if feature enabled)

# Parse and validate
nulid parse 01GZWQ22K2MNDR0GAQTE834QRV
nulid validate 01GZWQ22K2MNDR0GAQTE834QRV 01GZWQ22K2TKVGHH1Z1G0AK1EK

# Compare two NULIDs
nulid compare 01GZWQ22K2MNDR0GAQTE834QRV 01GZWQ22K2TKVGHH1Z1G0AK1EK
# Shows which is earlier and time difference in nanoseconds

# Sort NULIDs chronologically
nulid sort 01GZWQ22K2TKVGHH1Z1G0AK1EK 01GZWQ22K2MNDR0GAQTE834QRV
cat nulids.txt | nulid sort

# Decode to hex
nulid decode 01GZWQ22K2MNDR0GAQTE834QRV

UUID Commands (requires --features uuid)

# Convert NULID to UUID
nulid uuid 01GZWQ22K2MNDR0GAQTE834QRV

# Convert UUID to NULID
nulid from-uuid 018d3f9c-5a2e-7b4d-8f1c-3e6a9d2c5b7e

DateTime Commands (requires --features chrono)

# Convert NULID to ISO 8601 datetime
nulid datetime 01GZWQ22K2MNDR0GAQTE834QRV
# Output: 2024-01-01T00:00:00.123456789+00:00

# Create NULID from datetime
nulid from-datetime 2024-01-01T00:00:00Z

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;

    // Chrono DateTime (with `chrono` feature)
    #[cfg(feature = "chrono")]
    pub fn chrono_datetime(self) -> chrono::DateTime<chrono::Utc>;
    #[cfg(feature = "chrono")]
    pub fn from_chrono_datetime(dt: chrono::DateTime<chrono::Utc>) -> Result<Self>;

    // 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
  • chrono - Enable chrono::DateTime<Utc> conversion support
  • proto - Enable Protocol Buffers (protobuf) support for serialization

Examples:

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

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

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

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

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

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

# With chrono DateTime support
[dependencies]
nulid = { version = "0.5", features = ["chrono"] }

# With Protocol Buffers support
[dependencies]
nulid = { version = "0.5", features = ["proto"] }

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

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

For the proto example, see examples/proto_example.rs:

# Run the protobuf example
cargo run --example proto_example --features proto

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 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

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 for performance (11.78ns generation, 9.1ns 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