5 Real-World Bugs Caused by Bad UUID Implementations
UUIDs are marketed as a "just works" primitive — call a function, get a unique value, never think about it again. In practice, teams hit the same handful of failure modes over and over, usually discovered in production rather than code review. The five bugs below are composites drawn from patterns that recur across distributed systems, mobile clients, and database migrations. Each one traces the bug back to its root mechanical cause, shows the fix, and compares the approach that would have prevented it.
1. The Cloned-VM Collision: Duplicate UUIDv1 Values Across Containers
The bug
A backend team generated order IDs using UUIDv1, which embeds a 48-bit "node identifier" — historically the host's MAC address — alongside a timestamp. Their deployment pipeline built a golden VM image, then cloned it to spin up multiple worker containers on the same physical host at scale-out time.
Several containers, cloned from the same image at nearly the same moment, generated the exact same UUID for different orders, because their UUIDv1 implementation fell back to a randomly-seeded node ID generated once at process start — and the "randomness" was seeded from a low-entropy source (process start time) that was nearly identical across containers booted within the same second.
The mechanics
RFC 9562 permits two node ID strategies for UUIDv1: the real MAC address, or a randomly generated 48-bit value with the multicast bit set (used when a stable hardware address isn't available — which is the norm in containers). If that "random" value is seeded from something low-entropy and correlated across instances — like Environment.TickCount or a timestamp with second-level resolution — the collision resistance the spec assumes silently disappears.
// The actual root cause, buried in a third-party library:
private static byte[] GenerateNodeId()
{
var rnd = new Random(Environment.TickCount); // seeded per-process, low entropy,
// and highly correlated across
// containers started in the same second
var node = new byte[6];
rnd.NextBytes(node);
node[0] |= 0x01; // set multicast bit per RFC
return node;
}
Combined with a timestamp field that also has limited resolution under fast, parallel order creation, two containers booted in the same second could generate colliding (timestamp, clock_seq, node) triples.
The fix
// Seed node ID generation from a CSPRNG, not a low-entropy clock value
private static byte[] GenerateNodeId()
{
var node = new byte[6];
RandomNumberGenerator.Fill(node); // cryptographically secure
node[0] |= 0x01;
return node;
}
But the deeper fix was to stop using UUIDv1 for this purpose entirely. UUIDv7 avoids the node-ID concept altogether — it uses a CSPRNG for its random bits on every single call, so there's no per-process state to accidentally correlate across clones.
Guid orderId = Guid.CreateVersion7(); // no node ID, no per-process seed to leak
| Version | Depends on Node/MAC ID | Vulnerable to Clone Correlation | Recommended for Containers |
|---|---|---|---|
| UUIDv1 | Yes (or weak random fallback) | Yes | ❌ |
| UUIDv6 | Yes (same fallback risk) | Yes | ❌ |
| UUIDv4 | No | No | ✅ |
| UUIDv7 | No | No | ✅ |
2. The Silent Duplicate: Math.random()-Based IDs Under Load
The bug
A Node.js service generated "UUID-like" tracking IDs for analytics events using a hand-rolled function built on Math.random(), because a developer wanted to avoid pulling in a dependency. Under normal traffic, collisions were never observed in testing. During a traffic spike from a marketing campaign — tens of thousands of events per second across many worker processes — analytics started silently overwriting distinct events that happened to generate the same ID, because the ingestion pipeline used the ID as an upsert key.
The mechanics
Math.random() in V8 is a PRNG (xorshift128+) with a fixed internal state size, reseeded rarely, and explicitly documented as unsuitable for cryptographic or uniqueness-critical use. Worse, many Node.js cluster workers share process-startup timing, so their PRNG streams can be more correlated than a true per-process random source would be. The "1 in 5.3 undecillion" collision odds usually quoted for UUIDv4 assume a CSPRNG with full entropy — they do not apply to Math.random()-derived values.
// Bad: looks like a UUID, isn't cryptographically random
function fakeId() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
The fix
// Good: platform CSPRNG, RFC 9562 compliant
const id = crypto.randomUUID();
For high-throughput event pipelines specifically, also reconsider whether an upsert-by-ID pattern is safe at all without a secondary uniqueness guarantee (e.g., an idempotency key derived from actual event content via UUIDv5, rather than a purely random value used as a dedup key).
| Random Source | Effective Entropy | Safe for Uniqueness at Scale |
|---|---|---|
Math.random() |
Low, PRNG-dependent | ❌ |
crypto.randomUUID() |
122 bits, CSPRNG | ✅ |
crypto.getRandomValues() |
Full entropy, CSPRNG | ✅ |
3. The Cross-Account Lookup Failure: Case-Sensitive String Comparison
The bug
A mobile app team stored user session tokens as UUID strings and sent them to the backend in HTTP headers. iOS's UUID().uuidString returns uppercase hex digits; the backend, written against test data generated by Guid.NewGuid().ToString() in lowercase, compared incoming tokens against stored values using a case-sensitive string equality check in application code (bypassing the database's case-insensitive default collation).
Valid sessions from iOS clients failed lookup roughly 50% of the time depending on which layer performed the comparison, and the failure mode was worse than an outright error: the fallback logic treated a failed lookup as "no session," silently created a new guest session, and in one flow, briefly attached that guest session to data cached under the previous (still-technically-valid) session ID — a low-severity but real cross-session data leak.
The mechanics
RFC 9562 doesn't mandate a casing convention for the text representation; it only defines the canonical lowercase form as an example, while explicitly stating implementations should accept both cases on input. Plenty of platform libraries emit uppercase. The bug wasn't the casing difference itself — it was relying on raw string equality instead of comparing UUIDs as typed values (or at minimum, normalizing case before comparison).
// Bad: case-sensitive by construction
bool isValid = incomingToken == storedToken; // "50E8..." != "50e8..."
The fix
// Good: parse and compare as Guid — comparison is inherently case-insensitive
bool isValid = Guid.TryParse(incomingToken, out var parsed) && parsed == storedGuid;
-- Also good: normalize on write, and don't rely on column collation alone
UPDATE Sessions SET Token = LOWER(Token) WHERE Token <> LOWER(Token);
The broader lesson: never treat a UUID as "just a string" past the point where it enters your system. Parse it into your platform's native type immediately and keep it there until you must serialize it back out.
4. The Cross-Tenant Data Exposure: Endianness Mismatch in Binary Storage
The bug
A team migrated a multi-tenant SaaS database from CHAR(36) UUID storage to BINARY(16) in MySQL to save space and improve join performance (see the storage math in our Common UUID Mistakes post). The migration script used MySQL's UUID_TO_BIN() function with default arguments, which reorders the time-low/time-mid/time-high fields for better index locality on time-based UUIDs — a deliberate, documented behavior, not a straight big-endian byte copy.
The application layer, however, parsed the raw 16 bytes back into a .NET Guid assuming a straight byte layout. .NET's Guid constructor itself also reorders the first three fields (as little-endian) relative to the RFC's big-endian wire format. The result was two independent, incompatible reordering steps stacked on top of each other. For a subset of UUIDs, the double-reordering happened to produce a different, but still validly-formatted, UUID — one that in a handful of cases matched another real tenant's actual record ID, because the reordering was a deterministic bit permutation, not random corruption.
This surfaced as a small number of users occasionally seeing another tenant's record when following a direct link — a serious, though narrow-blast-radius, cross-tenant data exposure.
The mechanics
There is no single universal "binary UUID format." At least three conventions exist in practice:
| Convention | Field Order | Used By |
|---|---|---|
| RFC 9562 wire format (big-endian) | time_low, time_mid, time_hi_and_version, ... | Network protocols, most binary(16) stores |
UUID_TO_BIN(x, 1) (MySQL "swap flag") |
time_hi, time_mid, time_low reordered for sorting | MySQL 8+ optimized storage |
.NET Guid internal layout |
First 3 fields little-endian, rest big-endian | Guid.ToByteArray() / constructors |
Mixing any two of these without an explicit, tested conversion step produces a valid-looking but semantically wrong UUID — which is precisely why this bug wasn't caught by validation logic. A malformed UUID would have thrown an exception. A misinterpreted one just returns the wrong (but real) row.
The fix
// Explicit, tested conversion — don't rely on default constructor behavior
// when byte layout was produced by a database-specific function like
// MySQL's UUID_TO_BIN(x, 1).
public static Guid FromMySqlSwappedBinary(byte[] bin)
{
// Reverse MySQL's UUID_TO_BIN swap-flag reordering before handing
// bytes to Guid, and account for .NET's own little-endian fields.
var reordered = new byte[16];
Buffer.BlockCopy(bin, 4, reordered, 0, 2); // time_high -> time_low pos
Buffer.BlockCopy(bin, 6, reordered, 2, 2);
Buffer.BlockCopy(bin, 0, reordered, 4, 4);
Buffer.BlockCopy(bin, 8, reordered, 8, 8);
return new Guid(reordered); // now matches RFC field order, .NET applies its own endianness correctly
}
-- Always convert back with the matching flag, and add an integration
-- test that round-trips a known UUID through both layers.
SELECT BIN_TO_UUID(BinaryId, 1) FROM Tenants WHERE Id = ?;
The real fix, though, was process: add a round-trip integration test (generate → store → read → compare) for every boundary where UUIDs cross a serialization format, and never assume two systems' "16-byte UUID" means the same byte order.
5. The Duplicate-Key Storm: Sequential GUID Misuse Under Concurrent Batch Inserts
The bug
A team adopted SQL Server's NEWSEQUENTIALID() to fix index fragmentation from NEWID() (a legitimate, common fix — see mistake #1 in our companion post). They then built a batch import job that pre-generated GUIDs client-side using a naive "sequential GUID" algorithm (incrementing a shared byte array) to match the value they expected the database to assign, so they could reference child rows before the parent insert completed.
Under concurrent batch jobs (two imports running in parallel during a backlog catch-up), both processes incremented from the same starting snapshot, taken from a cached "last sequential GUID" value that wasn't refreshed atomically. Both batches generated overlapping ranges of IDs, and the second batch's insert failed on primary key violations for thousands of rows, mid-transaction, requiring a full rollback and replay.
The mechanics
NEWSEQUENTIALID() is server-generated only — it's guaranteed monotonic per SQL Server instance because the engine tracks the last value internally (including across restarts, using MAC address and boot count as inputs) and increments it atomically per call, similar in spirit to an IDENTITY column but for a 128-bit value. It is explicitly not something you can safely replicate client-side, because the atomicity guarantee lives inside the engine, not in the algorithm alone.
// Bad: assumes you can predict what NEWSEQUENTIALID() will generate,
// then generates client-side "sequential-looking" GUIDs from a shared,
// non-atomically-updated counter
private static byte[] _lastGuidBytes = InitialSeed();
public static Guid NextClientSideGuid()
{
IncrementBytes(_lastGuidBytes); // not thread-safe, not process-safe,
// not synchronized with the DB at all
return new Guid(_lastGuidBytes);
}
The fix
Don't try to predict or replicate server-generated sequential values. If you need the ID before the insert completes (for referencing child rows), generate it client-side using a version actually designed for that — UUIDv7 — and let the application, not the database, own ID generation entirely.
// Good: generate the ID up front, insert it explicitly, no coordination needed
var orderId = Guid.CreateVersion7();
using var tx = connection.BeginTransaction();
InsertOrder(orderId, ...);
InsertOrderLines(orderId, lineItems); // safe to reference immediately, no round-trip needed
tx.Commit();
CREATE TABLE Orders (
Id UNIQUEIDENTIFIER PRIMARY KEY, -- no DEFAULT; always supplied by the application
CreatedAt DATETIME2 NOT NULL
);
| Strategy | Predictable Before Insert | Safe Under Concurrency | Server Coordination Required |
|---|---|---|---|
NEWSEQUENTIALID() |
❌ | ✅ | Yes (built-in) |
| Client-side "sequential" hack | ✅ (unsafely) | ❌ | None — that's the problem |
| UUIDv7 generated client-side | ✅ | ✅ | None needed |
Summary
| # | Bug | Root Cause | Prevented By |
|---|---|---|---|
| 1 | Duplicate UUIDv1 across cloned containers | Low-entropy node ID seeding | CSPRNG seeding, or UUIDv7 |
| 2 | Silent event overwrites under load | Math.random() used for uniqueness |
crypto.randomUUID() |
| 3 | Failed session lookups, session leakage | Case-sensitive string comparison | Compare as typed Guid, not string |
| 4 | Cross-tenant data exposure | Stacked, incompatible binary byte-order conventions | Explicit, tested round-trip conversions |
| 5 | Mid-batch insert failures | Client-side replication of server-only sequential IDs | Client-generated UUIDv7 instead |
Every one of these bugs passed code review and unit tests. None of them involved an obviously "wrong" line of code in isolation — each was a mismatch between an assumption (about entropy, casing, byte order, or coordination guarantees) and what the UUID implementation actually provided. The fix, in every case, was the same habit: know exactly which version and representation you're using at each boundary, and never assume two "UUID" values from different layers of your stack mean the same 128 bits.