sparkid 2.2.1

Fast, time-sortable, 21-char Base58 unique ID generator
Documentation

⚡ sparkid

Fast, monotonic, time-sortable, 21-char Base58 unique ID generator. Only dependency is rand.

1ocmpHE1bFnygEBAPTzMK
1ocmpHE1bFnygFv4Wp4dL
1ocmpHE1bFnygGoUXUL7X

Install

cargo add sparkid

Usage

use sparkid::SparkId;

let id = SparkId::new();
// => "1ocmpHE1bFnygEBAPTzMK"

println!("{id}");              // Display, no heap allocation
let s = id.as_str();           // SparkIdStr — stack-allocated, Deref<str>
let owned: String = id.into(); // Into<String> when needed

Properties

Property Value
Length 21 characters, fixed
Alphabet Base58 (no 0, O, I, l)
Sortable Lexicographically, by creation time
Monotonic Strictly increasing within each thread
URL-safe Yes
Collision resistance ~5813 (~8.4 x 1022) combinations per millisecond
Randomness Cryptographically secure (rand / ChaCha12, seeded from OS)
Thread-safe Yes (via thread_local!)

How it works

Each ID is composed of two parts:

[8-char timestamp][13-char suffix]
  • Timestamp (8 chars): Current time in milliseconds, Base58-encoded. IDs generated in a later millisecond always sort after earlier ones.
  • Suffix (13 chars): Seeded from a cryptographically secure PRNG (rejection-sampled, no modulo bias) at the start of each millisecond, then monotonically incremented for each subsequent ID within that millisecond. This guarantees strict ordering even when multiple IDs share a timestamp.

SparkId type

SparkId is a stack-allocated, Copy type backed by a u128 — no heap allocation on creation. The 21 Base58 characters are bit-packed into 128 bits (6 bits per character), preserving sort order. It implements Display, Ord, Hash, FromStr, and Into<String>.

Use as_str() to get a SparkIdStr — a stack-allocated, Copy wrapper that dereferences to &str:

use sparkid::SparkId;

let a = SparkId::new();
let b = SparkId::new();
assert!(b > a);                      // Ord — monotonically increasing
println!("{a}");                      // Display — no allocation
let s = a.as_str();                  // SparkIdStr — Deref<str>, no heap
let slice: &str = &s;               // zero-cost &str access
let set: std::collections::HashSet<SparkId> = [a, b].into(); // Hash

Parse from string

use sparkid::SparkId;

let id = SparkId::new();
let parsed: SparkId = id.to_string().parse().unwrap();
assert_eq!(id, parsed);

Parsing validates that the input is exactly 21 characters and all characters are in the Base58 alphabet. Returns a ParseSparkIdError on failure.

Binary representation

SparkId is backed by a u128 (16 bytes) — smaller and faster to compare/sort than the 21-char string form. You can serialize the binary representation directly:

use sparkid::SparkId;

let id = SparkId::new();

// 16-byte big-endian binary — sort-preserving, memcmp-comparable
let bytes: [u8; 16] = id.to_bytes();
let restored = SparkId::from_bytes(bytes).unwrap();
assert_eq!(id, restored);

// Raw u128 — branchless comparison, ideal for in-memory sorting
let raw: u128 = id.as_u128();
let also_restored = SparkId::from_u128(raw).unwrap();
assert_eq!(id, also_restored);

Extract timestamp

use sparkid::SparkId;

let id = SparkId::new();

// As milliseconds since epoch (available in no_std)
let ms = id.timestamp_ms();

// As SystemTime (requires std)
let ts = id.timestamp();

Serde

Enable the serde feature for Serialize and Deserialize on both SparkId and SparkIdStr:

cargo add sparkid --features serde

Human-readable formats (JSON, TOML, etc.) serialize as the 21-char Base58 string. Binary formats (postcard, bincode, etc.) serialize as the 16-byte packed representation.

use sparkid::SparkId;

#[derive(serde::Serialize, serde::Deserialize)]
struct Record {
    id: SparkId,
    name: String,
}

Ordering guarantees

IDs from a single IdGenerator instance (or a single thread using SparkId::new()) are strictly monotonically increasing — every ID is lexicographically greater than the one before it.

Across threads, IDs are unique but not ordered relative to each other. Each thread gets its own generator via thread_local!, so there is no cross-thread coordination.

If you need process-wide monotonic ordering across threads, wrap a single IdGenerator in a Mutex:

use std::sync::{LazyLock, Mutex};
use sparkid::IdGenerator;

static GEN: LazyLock<Mutex<IdGenerator>> =
    LazyLock::new(|| Mutex::new(IdGenerator::new()));

fn generate_id_monotonic() -> sparkid::SparkId {
    GEN.lock().unwrap().next_id()
}

Advanced usage

For manual control, use the IdGenerator struct directly:

use sparkid::IdGenerator;

let mut gen = IdGenerator::new();
let id = gen.next_id();

IdGenerator also implements Iterator<Item = SparkId>:

let mut gen = sparkid::IdGenerator::new();
let ids: Vec<sparkid::SparkId> = gen.take(100).collect();

License

MIT