Skip to content

UUIDv7 Generation

UUIDv7 is a time-ordered UUID format defined in RFC 9562. Unlike UUIDv4 (random), UUIDv7 encodes a millisecond-resolution timestamp in the most-significant bits, making identifiers naturally sortable by creation time. This is especially useful in distributed systems where you want database-friendly, time-ordered primary keys without a centralised sequence generator.

Basic Usage

csharp
var factory = new UuidV7Factory(TimeProvider.System);
var id = factory.NewGuid();

UuidV7Factory accepts any TimeProvider, including TimeProvider.System for production use and SimulatedTimeProvider for tests.

By default, each factory owns a cryptographically secure random number generator for UUID random-tail bytes. You can pass a custom RandomNumberGenerator for deterministic tests, but production factories should use independent CSPRNG state.

With Simulated Time

csharp
var tp = new SimulatedTimeProvider();
var factory = new UuidV7Factory(tp);

var id1 = factory.NewGuid();

tp.Advance(TimeSpan.FromMilliseconds(1));

var id2 = factory.NewGuid();

// If you need temporal ordering, compare UUIDv7 in big-endian byte order.
Console.WriteLine(id1.CompareByTimestamp(id2) < 0); // true

Clockworks also includes helpers to decode UUIDv7 components:

csharp
Console.WriteLine(id2.GetTimestampMs()); // unix ms (nullable if not v7)
Console.WriteLine(id2.GetCounter());     // 12-bit counter (nullable if not v7)
Console.WriteLine(id2.IsVersion7());     // true/false

Monotonicity Guarantees

Within a single factory instance, ordering is maintained by a 12-bit monotonic counter appended to the 48-bit timestamp:

  • When physical time moves to a new millisecond, the counter is reset to a random start value (masked into the lower half of the counter space to leave room for increments).
  • Each successive NewGuid() within the same millisecond increments the counter.
  • If the wall clock moves backwards, the factory continues from the last logical (timestamp, counter) frontier instead of rewinding to the lower physical time.
  • If the counter overflows (4,096 values exhausted), behavior depends on CounterOverflowBehavior (spin-wait, increment timestamp, or throw).

This guarantees strict per-instance monotonicity without locks. The guarantee is scoped to the live factory instance, not to every factory in a fleet.

Collision and Clock-Skew Semantics

UuidV7Factory separates local deterministic allocation from distributed probabilistic uniqueness:

ScopeGuarantee
One live factory instanceDeterministic, lock-free allocation of unique and monotonically increasing (timestamp, counter) pairs. Backward wall-clock movement does not rewind the logical frontier.
Multiple threads sharing one factorySame per-instance guarantee; CAS retries may occur under contention, but successful allocations do not reuse a pair.
Multiple factories in one processNo shared logical frontier. Full UUID collisions are still extremely unlikely with independent CSPRNG state, but uniqueness is probabilistic.
Multiple processes or machinesNo built-in global coordination or node discriminator in UuidV7Factory. Clock skew can increase timestamp overlap, while uniqueness depends on randomized counter starts and the 62-bit random tail.
Process restart without restored stateThe new factory starts from current wall time and a random counter start. It does not inherit the previous logical frontier.
Process restart with restored stateThe new factory starts at or above the restored logical frontier. This prevents rollback relative to the persisted cursor, but it does not coordinate multiple live writers.

For production services, prefer a single UuidV7Factory singleton per process or service instance. The built-in DI helpers register it this way.

For high-assurance shared namespaces, use a storage uniqueness constraint as the final guardrail and retry on conflict. If you need node-aware ordering semantics, consider HlcGuidFactory; it embeds a node ID and HLC timestamp, but it should be chosen for causal/node-aware ordering rather than treated as a blanket substitute for storage-level uniqueness.

Node Partitioning

For fleets that can assign stable node, shard, process, or deployment IDs, UuidV7Factory can reserve 1 to 16 of the most-significant UUIDv7 rand_b bits as a deterministic partition:

csharp
var partition = new UuidV7NodePartition(nodeId: 42, nodeIdBitWidth: 10);
using var factory = new UuidV7Factory(TimeProvider.System, partition);

var id = factory.NewGuid();
Console.WriteLine(id.GetNodePartitionId(10)); // 42

This is opt-in. The default factory keeps all 62 effective rand_b bits random. Partitioning trades entropy for deterministic namespace separation: a 10-bit partition supports 1,024 IDs and leaves 52 random bits; a 16-bit partition supports 65,536 IDs and leaves 46 random bits.

The built-in DI helpers can register a node-partitioned singleton:

csharp
services.AddNodePartitionedGuidFactory(
    new UuidV7NodePartition(nodeId: 42, nodeIdBitWidth: 10));

See UUIDv7 Node Partitioning for the design trade-offs.

Restart State

UuidV7FactoryState captures the factory's current logical frontier as a compact (timestampMs, counter) cursor. Persisting this state lets a later process restore the frontier after a restart:

csharp
using var factory = new UuidV7Factory(TimeProvider.System);

var id = factory.NewGuid();
var state = factory.GetState();

Span<byte> buffer = stackalloc byte[UuidV7FactoryState.EncodedLength];
state.WriteTo(buffer);

// Persist the eight bytes durably with your application's checkpoint.

To restore:

csharp
var restored = UuidV7FactoryState.ReadFrom(persistedBytes);
using var factory = new UuidV7Factory(TimeProvider.System, restored);

Checkpointing is exposed on the concrete UuidV7Factory type. The narrower IUuidV7Factory interface remains generation-only so alternative implementations are not forced into the same persistence model.

Correctness invariant:

text
next_generated_frontier > restored_frontier

More precisely, if the restored timestamp is equal to or ahead of physical time, the factory initializes from the restored cursor and the next UUID advances it. If physical time is already ahead of the restored timestamp, the factory uses physical time and a fresh random counter start. Either way, future allocations do not move below the restored frontier.

Important operational limits:

  • Persist state after the UUIDs it covers are durably committed. A crash after issuing a UUID but before persisting the new state can still lose the last frontier.
  • Restored state coordinates one replacement factory. It does not coordinate multiple live factories restoring the same cursor.
  • Use node partitioning, HlcGuidFactory, external allocation, or storage uniqueness constraints when multiple writers share a namespace.
  • If you restore a max-counter state while physical time is behind the restored logical timestamp, SpinWait waits until physical time passes that logical timestamp. For deterministic simulations and large restored drift windows, prefer CounterOverflowBehavior.Auto or IncrementTimestamp.

See UUIDv7 Restart State for the design notes and failure modes.

Statistics

UuidV7Factory statistics are opt-in. Leave them disabled for the lowest-overhead path, or pass a UuidV7FactoryStatistics instance when you want to observe clock rollback, counter overflow, spin-wait pressure, and lock-free contention:

csharp
var stats = new UuidV7FactoryStatistics();
using var factory = new UuidV7Factory(
    TimeProvider.System,
    rng: null,
    overflowBehavior: CounterOverflowBehavior.SpinWait,
    statistics: stats);

var id = factory.NewGuid();
var snapshot = stats.Snapshot();

Console.WriteLine(snapshot.GeneratedCount);
Console.WriteLine(snapshot.CounterOverflowCount);

The built-in DI helpers can register the same shared statistics object as the singleton factory:

csharp
services.AddLockFreeGuidFactory(new UuidV7FactoryStatistics());

Key counters:

CounterMeaning
GeneratedCountNumber of UUIDs successfully generated. Batch generation increments by the span length.
ClockRollbackCountSuccessful allocation decisions made while physical time was behind the factory's logical frontier.
CounterOverflowCountTimes the 12-bit per-millisecond counter overflow path was reached.
SpinWaitCountTimes overflow handling had to wait for physical time to advance.
LogicalTimestampAdvanceCountSuccessful allocation decisions that emitted logical time ahead of physical time.
MaxLogicalDriftMsMaximum observed distance between emitted logical time and physical time.
CasRetryCountFailed compare-exchange attempts in the lock-free allocation loop.
RandomBufferRefillCountThread-local random buffer refills.

Most counters are diagnostic event counts, not rates. For example, a single NewGuids(span) call may reserve many UUIDs with one successful allocation decision, so GeneratedCount increases by span.Length while rollback or drift counters increase once for that reservation. Under lock-free contention, overflow and spin-wait counters describe observed path entries and wait attempts rather than a globally serialized event log.

Custom RNGs and Deterministic Tests

Custom RNG injection exists so tests and simulations can replay exact UUID sequences. Clockworks does not ship a deterministic RNG implementation; provide your own test-only RandomNumberGenerator when you need replay.

Identical deterministic RNG state, identical time, and identical call patterns intentionally produce identical UUIDv7 output:

csharp
var time = SimulatedTimeProvider.FromUnixMs(1_700_000_000_000);
using RandomNumberGenerator rng = new MyDeterministicTestRng(seed: 42); // your test-only implementation
using var factory = new UuidV7Factory(time, rng);

This is useful for fixtures, demos, and simulation replay. It is not production entropy. In production, leave rng as null unless you are supplying a cryptographically secure generator with independent state for each factory.

When modeling independent deterministic nodes in tests, derive a separate deterministic RNG stream per node instead of reusing the same seed directly. Reusing the same deterministic stream across factories is a way to model exact replay, not independent UUID issuance.

Counter Overflow Behavior

The overflow behavior is configurable via the constructor:

csharp
var factory = new UuidV7Factory(
    timeProvider: TimeProvider.System,
    overflowBehavior: CounterOverflowBehavior.SpinWait // default
);
BehaviorDescription
SpinWaitBusy-waits until physical time passes the exhausted logical millisecond (default)
IncrementTimestampArtificially increments the timestamp to maintain throughput (timestamp may drift ahead)
ThrowExceptionThrows if more than 4096 UUIDs are allocated in a single millisecond
AutoChooses IncrementTimestamp for SimulatedTimeProvider (avoids deadlocks), otherwise SpinWait

The same policy applies when logical time is ahead of physical time because of clock rollback or restored restart state. For example, if a restored frontier has counter = 4095 and physical time is still behind that frontier, SpinWait waits for physical catch-up, IncrementTimestamp advances logical time, and ThrowException fails immediately.

Simulated time + overflow

If you use SimulatedTimeProvider and generate more than 4096 UUIDs without advancing time, SpinWait can deadlock (simulated time won't move forward on its own). For simulations, prefer Auto or IncrementTimestamp.

Thread Safety

UuidV7Factory uses a lock-free generation path. Multiple threads can call NewGuid() concurrently and receive unique, monotonically ordered results.

Batch generation

To reduce call overhead, you can fill a span in one call:

csharp
var factory = new UuidV7Factory(TimeProvider.System);
Span<Guid> ids = stackalloc Guid[128];
factory.NewGuids(ids);

Disposal

UuidV7Factory owns a cryptographically-secure RNG by default. Dispose the factory when you're done with it:

csharp
using var factory = new UuidV7Factory(TimeProvider.System);
var id = factory.NewGuid();

Security Considerations

UUIDv7 embeds a timestamp — do not use as an opaque token at public boundaries

UUIDv7 values embed a millisecond-resolution timestamp by design. Any UUIDv7 can be decoded to reveal an approximate creation time, and ordering/rate information can sometimes be inferred from sequences of IDs.

If you are issuing identifiers across untrusted/public boundaries (URLs, externally-visible resource IDs, third-party logs), do not treat UUIDv7 as opaque. Common mitigations:

  • Use a random UUID (e.g., UUIDv4) for externally-visible identifiers.
  • Keep UUIDv7 as an internal primary key, and expose a separate opaque token externally.
  • Wrap/encrypt identifiers for external presentation if you need internal ordering but external opacity.

See Security Considerations for a full discussion.

Performance

From the property-based performance tests:

  • 60,000 sequential UUIDs complete in under 1 second
  • 50,000 concurrent UUIDs (parallel generation) complete in under 1 second

Running the Demos

bash
# Basic UUIDv7 generation and time decoding
dotnet run --project demo/Clockworks.Demo -- uuidv7

# Sortability demonstration
dotnet run --project demo/Clockworks.Demo -- uuidv7-sortability

# Benchmark mode
dotnet run --project demo/Clockworks.Demo -- uuidv7 --bench

Benchmark mode includes a side-by-side single-threaded comparison of the default hot path and statistics-enabled hot path. It also compares node-partitioned generation against default NewGuid() and NewGuids(Span<Guid>).

Released under the MIT License.