Skip to content

Instantly share code, notes, and snippets.

@senrecep
Last active June 3, 2026 10:46
Show Gist options
  • Select an option

  • Save senrecep/606365468ff9fce4c9684ceae61d5351 to your computer and use it in GitHub Desktop.

Select an option

Save senrecep/606365468ff9fce4c9684ceae61d5351 to your computer and use it in GitHub Desktop.
UUIDv7 in Go, three ways — stateless (like .NET's Guid.CreateVersion7), mutex-monotonic (like google/uuid), and lock-free monotonic via atomic CAS. Runnable, RFC 9562 compliant, with race tests & benchmarks.

Three ways to make a UUIDv7 in Go

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.

The three generators

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.

Running it

go run .            # prints a burst from each generator
go test -race -v    # correctness and concurrency safety
go test -bench . -benchmem

Test results

Everything passes with the race detector on (go 1.25.5, Apple M3 Pro):

  • TestRFC9562: 30,000 IDs, every one carries version 7 and variant 10
  • TestMonotonic: 100,000 sequential IDs per generator, strictly increasing
  • TestPackNowCarriesCounterIntoTimestamp: overflow past the 12-bit counter carries into the encoded timestamp
  • TestConcurrentMonotonicSlots: 64 goroutines minting 5,000 monotonic IDs each, zero packed counter-slot collisions
  • TestConcurrentUnique: 64 goroutines minting 5,000 IDs each, zero collisions
ok  	go-uuid	5.102s

Benchmarks

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.

Where the ideas come from

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.

// UUIDv7 three ways: stateless, mutex-monotonic, and lock-free monotonic.
//
// A v7 UUID is a 48-bit big-endian millisecond timestamp followed by random
// bits, so IDs sort by creation time. The only real question is how to order
// IDs minted within the same millisecond:
//
// NewV7 stateless random fill, no intra-ms ordering (like .NET's Guid.CreateVersion7).
// NewV7Monotonic mutex over a packed (ms<<12 | counter), strictly increasing (like google/uuid).
// NewV7Lockfree same guarantee via an atomic CAS loop instead of a mutex.
//
// Randomness comes from a pooled CSPRNG: crypto/rand is syscall-backed, so a
// pooled buffer refilled in large chunks is ~6x faster than reading a
// few bytes per call. The monotonic variants read only the 8 bytes they keep.
package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"sync"
"sync/atomic"
"time"
)
type UUID [16]byte
// fillRandom writes len(dst) random bytes (dst ≤ 16) from a pooled buffer that
// refills from crypto/rand in 4 KiB chunks, amortizing the syscall.
func fillRandom(dst []byte) error {
rc := randPool.Get().(*randChunk)
defer randPool.Put(rc)
if rc.pos+len(dst) > len(rc.buf) {
if _, err := rand.Read(rc.buf[:]); err != nil {
return err
}
rc.pos = 0
}
copy(dst, rc.buf[rc.pos:rc.pos+len(dst)])
rc.pos += len(dst)
return nil
}
type randChunk struct {
buf [4096]byte
pos int
}
var randPool = sync.Pool{New: func() any { return &randChunk{pos: 4096} }}
// writeTimestamp stamps the low 48 bits of ms into the first six bytes.
func writeTimestamp(u *UUID, ms uint64) {
u[0], u[1], u[2] = byte(ms>>40), byte(ms>>32), byte(ms>>24)
u[3], u[4], u[5] = byte(ms>>16), byte(ms>>8), byte(ms)
}
// setVersionVariant sets the version-7 nibble and the 10xx variant bits.
func setVersionVariant(u *UUID) {
u[6] = (u[6] & 0x0f) | 0x70
u[8] = (u[8] & 0x3f) | 0x80
}
// NewV7 stamps a timestamp over random bytes. Ordering within a millisecond is
// left to chance.
func NewV7() (UUID, error) {
var u UUID
if err := fillRandom(u[:]); err != nil {
return UUID{}, err
}
writeTimestamp(&u, uint64(time.Now().UnixMilli()))
setVersionVariant(&u)
return u, nil
}
// packNow returns a strictly increasing (ms<<12 | 12-bit counter) value, seeded
// from the sub-millisecond clock so bursts spread out instead of starting at 0.
// If the clock stalls or steps back, it advances one tick past last.
func packNow(last uint64) uint64 {
nano := time.Now().UnixNano()
ms := uint64(nano / int64(time.Millisecond))
seq := uint64((nano%int64(time.Millisecond))>>8) & 0x0fff
packed := ms<<12 | seq
if packed <= last {
packed = last + 1
}
return packed
}
// writePacked lays a packed value into the timestamp and rand_a counter fields.
func writePacked(u *UUID, packed uint64) {
writeTimestamp(u, packed>>12)
seq := uint16(packed & 0x0fff)
u[6] = 0x70 | byte(seq>>8) // version 7 + counter high nibble
u[7] = byte(seq) // counter low byte
u[8] = (u[8] & 0x3f) | 0x80
}
// NewV7Monotonic guards the counter with a mutex.
var (
v7mu sync.Mutex
v7last uint64
)
func NewV7Monotonic() (UUID, error) {
var u UUID
if err := fillRandom(u[8:]); err != nil {
return UUID{}, err
}
v7mu.Lock()
v7last = packNow(v7last)
packed := v7last
v7mu.Unlock()
writePacked(&u, packed)
return u, nil
}
// NewV7Lockfree advances the counter with an atomic CAS loop.
var v7atomic atomic.Uint64
func NewV7Lockfree() (UUID, error) {
var u UUID
if err := fillRandom(u[8:]); err != nil {
return UUID{}, err
}
var packed uint64
for {
last := v7atomic.Load()
packed = packNow(last)
if v7atomic.CompareAndSwap(last, packed) {
break
}
}
writePacked(&u, packed)
return u, nil
}
// String renders the canonical 8-4-4-4-12 hex form.
func (u UUID) String() string {
var b [36]byte
hex.Encode(b[0:8], u[0:4])
hex.Encode(b[9:13], u[4:6])
hex.Encode(b[14:18], u[6:8])
hex.Encode(b[19:23], u[8:10])
hex.Encode(b[24:36], u[10:16])
b[8], b[13], b[18], b[23] = '-', '-', '-', '-'
return string(b[:])
}
func main() {
gen := func(name string, fn func() (UUID, error)) {
fmt.Println(name)
for i := 0; i < 4; i++ {
u, err := fn()
if err != nil {
log.Fatal(err)
}
fmt.Println(" ", u)
}
}
gen("stateless:", NewV7)
gen("mutex monotonic:", NewV7Monotonic)
gen("lock-free monotonic:", NewV7Lockfree)
}
// Tests for the three UUIDv7 generators. Run them with:
//
// go test -race -v # correctness + concurrency safety
// go test -bench . -benchmem
package main
import (
"bytes"
"sort"
"sync"
"testing"
"time"
)
// generators under test, by name.
var generators = map[string]func() (UUID, error){
"stateless": NewV7,
"mutex": NewV7Monotonic,
"lockfree": NewV7Lockfree,
}
// monotonic generators promise intra-millisecond ordering; stateless does not.
var monotonic = map[string]func() (UUID, error){
"mutex": NewV7Monotonic,
"lockfree": NewV7Lockfree,
}
func packedFromUUID(u UUID) uint64 {
ms := uint64(u[0])<<40 | uint64(u[1])<<32 | uint64(u[2])<<24 |
uint64(u[3])<<16 | uint64(u[4])<<8 | uint64(u[5])
seq := uint64(u[6]&0x0f)<<8 | uint64(u[7])
return ms<<12 | seq
}
// TestRFC9562 checks the two fixed fields every v7 UUID must carry: the version
// nibble (high nibble of byte 6) is 7 and the variant (top two bits of byte 8)
// is 0b10.
func TestRFC9562(t *testing.T) {
for name, gen := range generators {
t.Run(name, func(t *testing.T) {
for i := 0; i < 10000; i++ {
u, err := gen()
if err != nil {
t.Fatal(err)
}
if v := u[6] >> 4; v != 0x7 {
t.Fatalf("version = %#x, want 7 (%s)", v, u)
}
if v := u[8] >> 6; v != 0b10 {
t.Fatalf("variant = %#b, want 10 (%s)", v, u)
}
}
})
}
}
// TestMonotonic verifies the monotonic generators are strictly increasing when
// called from one goroutine — even for bursts within a single millisecond.
func TestMonotonic(t *testing.T) {
for name, gen := range monotonic {
t.Run(name, func(t *testing.T) {
prev, err := gen()
if err != nil {
t.Fatal(err)
}
for i := 1; i < 100000; i++ {
cur, err := gen()
if err != nil {
t.Fatal(err)
}
if bytes.Compare(cur[:], prev[:]) <= 0 {
t.Fatalf("not increasing at %d: %s !> %s", i, cur, prev)
}
prev = cur
}
})
}
}
func TestPackNowCarriesCounterIntoTimestamp(t *testing.T) {
futureMS := uint64(time.Now().Add(time.Hour).UnixMilli())
last := futureMS<<12 | 0x0fff
packed := packNow(last)
if packed != last+1 {
t.Fatalf("packed = %#x, want last+1 %#x", packed, last+1)
}
if seq := packed & 0x0fff; seq != 0 {
t.Fatalf("seq = %#x, want carried counter to wrap to 0", seq)
}
if ms := packed >> 12; ms != futureMS+1 {
t.Fatalf("ms = %d, want %d", ms, futureMS+1)
}
}
func TestConcurrentMonotonicSlots(t *testing.T) {
const goroutines, perG = 64, 5000
for name, gen := range monotonic {
t.Run(name, func(t *testing.T) {
var mu sync.Mutex
slots := make([]uint64, 0, goroutines*perG)
var wg sync.WaitGroup
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func() {
defer wg.Done()
batch := make([]uint64, perG)
for i := range batch {
u, err := gen()
if err != nil {
t.Error(err)
return
}
batch[i] = packedFromUUID(u)
}
mu.Lock()
slots = append(slots, batch...)
mu.Unlock()
}()
}
wg.Wait()
if len(slots) != goroutines*perG {
t.Fatalf("got %d slots, want %d", len(slots), goroutines*perG)
}
sort.Slice(slots, func(i, j int) bool { return slots[i] < slots[j] })
for i := 1; i < len(slots); i++ {
if slots[i] == slots[i-1] {
t.Fatalf("duplicate packed slot %#x at sorted index %d", slots[i], i)
}
}
})
}
}
// TestConcurrentUnique fans out across goroutines and asserts every ID is
// unique. For the monotonic generators a collision would also mean two callers
// got the same counter slot (a lost mutex/CAS update). Run with -race.
func TestConcurrentUnique(t *testing.T) {
const goroutines, perG = 64, 5000
for name, gen := range generators {
t.Run(name, func(t *testing.T) {
var mu sync.Mutex
seen := make(map[UUID]struct{}, goroutines*perG)
var wg sync.WaitGroup
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func() {
defer wg.Done()
batch := make([]UUID, perG)
for i := range batch {
u, err := gen()
if err != nil {
t.Error(err)
return
}
batch[i] = u
}
mu.Lock()
for _, u := range batch {
seen[u] = struct{}{}
}
mu.Unlock()
}()
}
wg.Wait()
if len(seen) != goroutines*perG {
t.Fatalf("got %d unique, want %d (collisions)", len(seen), goroutines*perG)
}
})
}
}
func BenchmarkSerial(b *testing.B) {
for name, gen := range generators {
b.Run(name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
gen()
}
})
}
}
func BenchmarkParallel(b *testing.B) {
for name, gen := range generators {
b.Run(name, func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
gen()
}
})
})
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment