A UUIDv7 is a timestamp with random bits glued on. The first 48 bits hold the Unix time in milliseconds, big-endian, so the IDs sort by when you made them. That is what makes them good database keys. The catch shows up the moment you mint several inside the same millisecond: what decides their order?
This gist answers that three different ways, all in one runnable file.
| Function | Intra-ms ordering | How it works | Mirrors |
|---|---|---|---|
NewV7 |
none | fills the rest with random bytes | .NET's Guid.CreateVersion7 |
NewV7Monotonic |
strict | counter behind a mutex | google/uuid |
NewV7Lockfree |
strict | counter via an atomic CAS loop | its own thing |
All three set the version-7 nibble and the 10xx variant bits, so every value is a valid RFC 9562 UUIDv7.
The monotonic two pack a (timestamp, 12-bit counter) pair into a single number and never let it stand still. If the clock stalls or steps backward, the counter ticks one past the last value instead of handing back something that sorts out of order. If a process needs more than 4,096 monotonic IDs inside one real millisecond, that same carry moves the encoded timestamp slightly into the future so byte ordering stays strict.
Random bytes come from crypto/rand through a pooled 4 KiB buffer. The generator functions still return an error because the CSPRNG read can fail, and callers should treat that as fatal for ID generation.
go run . # prints a burst from each generator
go test -race -v # correctness and concurrency safety
go test -bench . -benchmemEverything passes with the race detector on (go 1.25.5, Apple M3 Pro):
TestRFC9562: 30,000 IDs, every one carries version 7 and variant 10TestMonotonic: 100,000 sequential IDs per generator, strictly increasingTestPackNowCarriesCounterIntoTimestamp: overflow past the 12-bit counter carries into the encoded timestampTestConcurrentMonotonicSlots: 64 goroutines minting 5,000 monotonic IDs each, zero packed counter-slot collisionsTestConcurrentUnique: 64 goroutines minting 5,000 IDs each, zero collisions
ok go-uuid 5.102s
Same machine, go test -bench . -benchmem. Every generator does zero heap allocations.
| Benchmark | ns/op | allocs/op |
|---|---|---|
| Serial / stateless | 44.38 | 0 |
| Serial / mutex | 47.90 | 0 |
| Serial / lockfree | 44.55 | 0 |
| Parallel / stateless | 17.71 | 0 |
| Parallel / mutex | 132.2 | 0 |
| Parallel / lockfree | 312.7 | 0 |
Two things stand out.
First, pooling the randomness matters more than anything else. crypto/rand goes through a syscall, so reading a handful of bytes on every call is basically the whole cost. Borrowing from a pooled buffer that refills in 4 KiB chunks dropped the serial numbers from about 250 ns to about 45 ns. Call it roughly 5-6x, and it costs no heap allocations.
Second, lock-free is not the fast one. Under heavy contention the CAS loop keeps losing the race and retrying, while the mutex version gets parked and woken by the runtime more gracefully. The mutex runs roughly 2.4x faster in parallel here. So much for the name.
The stateless version is what .NET ships in Guid.CreateVersion7: timestamp on top, random everywhere else, no promises about order within a millisecond. The mutex-monotonic version follows google/uuid, which guards a packed counter (timeMu plus lastV7time) and bumps it by one when the clock does not move. The lock-free variant keeps that same guarantee but trades the mutex for an atomic. As the benchmarks show, that trade is not always a win.