Monotonic ULIDs Are Not Automatically Thread-Safe

Published

Monotonic ULID generation keeps state between calls. Sharing that state between concurrent writers requires synchronization, even when the underlying random-byte source is safe to share.

The memcue fix

Memcue PR #200, merged on October 21, 2026, wraps its package-level ULID entropy source with ulid.LockedMonotonicReader. The previous code shared an unlocked ulid.Monotonic value across calls and incorrectly described it as safe for concurrent use.

The application generated IDs before entering its SQLite transaction. Serializing database access through one connection therefore did not serialize ID generation. Concurrent calls to AddBatch could race on the entropy source and produce duplicate or decreasing IDs. The PR notes that the CLI wrote serially; the exposed Store API allowed the concurrent use that made the problem relevant.

What the lock protects

The oklog/ulid implementation documents that Monotonic is unsafe for concurrent use. It remembers the previous timestamp and entropy value, incrementing the entropy for repeated calls with the same millisecond. Those reads and updates form a stateful operation.

LockedMonotonicReader synchronizes the monotonic reads used by ulid.New with a mutex. This protects the shared state in that generation path. A thread-safe random source alone does not protect the mutable state of the monotonic wrapper around it.

Define the ordering you need

A synchronized shared source lets callers safely use one monotonic sequence for a given millisecond. Independent entropy sources per worker avoid sharing that mutable state, but do not establish a single monotonic order across workers. Each source also needs suitable randomness; cloning identical random-generator state can undermine uniqueness.

As a design consequence, locking entropy should not be treated as a promise that IDs follow database commit order or request completion order. Those events happen outside the protected operation. Clock rollback is another separate policy: an entropy lock does not make caller-supplied timestamps increase.

Test the public write path concurrently

Memcue added a regression test that runs 16 concurrent batch writes, checks that all returned IDs are unique, and runs under Go's race detector. That exercises the application boundary where the shared source was actually used.

  • Identify every caller that shares a monotonic entropy source.
  • Check whether ID generation occurs inside the synchronization boundary.
  • Run concurrent writes with the race detector and check uniqueness.
  • Test ordering separately at the boundary where your application promises it, especially for calls in the same millisecond.

Explore the ULID generator and the related ULID parsing requirements for case handling and the 128-bit range.


Browse all UUID articles →