Common UUID Mistakes Developers Make (and How to Avoid Them)
UUIDs look deceptively simple: call a function, get back 36 characters, move on. In practice, they're one of the most frequently misused primitives in modern software — misused in ways that don't show up in local development, only in production, at scale, six months after launch. This post walks through the mistakes that recur across codebases and database schemas, why each one hurts, and the concrete fix.
1. Using Random UUIDs (v4) as a Clustered Primary Key
This is the single most common — and most expensive — UUID mistake in relational databases.
The mechanics
A UUIDv4 is ~122 bits of cryptographically random data. When you use it as a clustered index key (the default for a SQL Server PRIMARY KEY, or an InnoDB table in MySQL/MariaDB), every single insert has to land at a random position in the B-tree, not at the end.
That causes:
- Page splits — pages fill up out of order, forcing the engine to split and rebalance nodes constantly.
- Poor cache locality — the "hot" working set of pages the database needs in memory keeps growing because inserts scatter across the whole index instead of hitting the last page.
- Index bloat and fragmentation — measurable, ongoing fragmentation that requires regular
REBUILD/REORGANIZEmaintenance.
The fix: use a time-ordered UUID, or don't cluster on the UUID at all
-- Bad: UUIDv4 as clustered PK in SQL Server
CREATE TABLE Orders (
Id UNIQUEIDENTIFIER DEFAULT NEWID() PRIMARY KEY, -- random, fragments badly
CustomerId UNIQUEIDENTIFIER NOT NULL,
CreatedAt DATETIME2 NOT NULL
);
-- Better: sequential GUIDs for the clustering key
CREATE TABLE Orders (
Id UNIQUEIDENTIFIER DEFAULT NEWSEQUENTIALID() PRIMARY KEY, -- monotonic-ish
CustomerId UNIQUEIDENTIFIER NOT NULL,
CreatedAt DATETIME2 NOT NULL
);
// Better still: generate UUIDv7 in application code (time-ordered, RFC 9562 compliant)
using System;
Guid orderId = Guid.CreateVersion7(); // .NET 9+, RFC 9562 UUIDv7
If you're on an older .NET runtime without native CreateVersion7, use a library (e.g. UUIDNext) rather than hand-rolling the bit layout — getting the version/variant nibbles wrong (see mistake #5) is easy to do manually.
Performance comparison
| Approach | Insert Pattern | Index Fragmentation | Typical Fix Needed |
|---|---|---|---|
UUIDv4 (NEWID() / Guid.NewGuid()) |
Fully random | High | Frequent ALTER INDEX REBUILD |
NEWSEQUENTIALID() |
Mostly ascending | Low–Medium | Occasional maintenance |
| UUIDv7 | Ascending by millisecond | Low | Rarely needed |
BIGINT IDENTITY |
Strictly ascending | Minimal | Not applicable |
Rule of thumb: if the UUID is your clustering key, it needs to be time-ordered. If you want pure random UUIDs for external-facing IDs, keep them as a non-clustered UNIQUE column and cluster on something else (an IDENTITY/BIGINT, or a UUIDv7).
2. Storing UUIDs as VARCHAR(36) Instead of a Native/Binary Type
The mechanics
A UUID is 128 bits — 16 bytes. Stored as a hyphenated string ("550e8400-e29b-41d4-a716-446655440000"), it becomes a 36-character string, which at minimum consumes 36 bytes and often more depending on collation and encoding (e.g. NVARCHAR doubles it to 72 bytes).
That's a 2.25x–4.5x storage penalty, multiplied across every row and every index that includes the column — and string comparison for joins/sorts is slower than fixed-width binary comparison.
| Storage Type | Bytes per UUID | Comparison Cost |
|---|---|---|
UNIQUEIDENTIFIER (SQL Server) |
16 | Native, fast |
BINARY(16) / RAW(16) |
16 | Native, fast |
CHAR(36) / VARCHAR(36) |
36 | Slower, locale-aware |
NVARCHAR(36) |
72 | Slowest |
The fix
-- Bad
CREATE TABLE Users (
Id VARCHAR(36) PRIMARY KEY,
Email NVARCHAR(255)
);
-- Good (SQL Server)
CREATE TABLE Users (
Id UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
Email NVARCHAR(255)
);
-- Good (MySQL/Postgres — no native GUID type in MySQL, so use BINARY(16))
CREATE TABLE Users (
Id BINARY(16) PRIMARY KEY,
Email VARCHAR(255)
);
Only convert to the string form at the API/presentation boundary, never at the storage layer.
3. Assuming String Sort Order Matches Binary Sort Order
The mechanics
This one is subtle and SQL Server-specific, but it trips up developers who assume ORDER BY UniqueIdentifierColumn behaves like sorting the string representation. SQL Server's UNIQUEIDENTIFIER sorts by comparing byte groups in a non-intuitive order — the last 6 bytes are compared before some of the earlier bytes, due to legacy Windows GUID byte-swapping (little-endian storage of the first three fields).
This means a UUIDv7's embedded timestamp, which sorts correctly as a plain byte array or as a BINARY(16) in MySQL/Postgres, does not sort chronologically when stored as a SQL Server UNIQUEIDENTIFIER and ordered naively.
-- This does NOT reliably return rows in creation order for UUIDv7 values
-- stored as UNIQUEIDENTIFIER in SQL Server:
SELECT * FROM Orders ORDER BY Id;
The fix
- On MySQL/Postgres (
BINARY(16)/BYTEA), byte-array comparison matches UUIDv7's intended chronological order — no special handling needed. - On SQL Server, either store the UUID's raw bytes in the exact big-endian order it was generated in and compare via
CONVERT(BINARY(16), Id)semantics carefully, or — simpler — keep a separateCreatedAt DATETIME2column and sort by that instead of relying on GUID byte ordering.
// Don't rely on Guid.CompareTo() for chronological ordering either —
// .NET's Guid comparison follows the same SQL Server-style field ordering,
// not a straight byte-array comparison.
4. Treating UUIDs as a Substitute for Authorization
The mechanics
A common pattern: GET /api/invoices/{uuid} with no additional access check, on the theory that "you can't guess a UUID." This is security by obscurity, not authorization. UUIDs can leak through:
- Browser history and referrer headers
- Server logs and error trackers (Sentry, Application Insights)
- Shared links, screenshots, browser extensions
- Timing/side-channel attacks in poorly implemented systems
An unguessable ID is not the same as an authorized request.
The fix
// Bad
[HttpGet("invoices/{id}")]
public IActionResult GetInvoice(Guid id)
{
var invoice = _db.Invoices.Find(id);
return Ok(invoice); // no ownership check!
}
// Good
[HttpGet("invoices/{id}")]
public IActionResult GetInvoice(Guid id)
{
var invoice = _db.Invoices.Find(id);
if (invoice is null || invoice.OwnerId != CurrentUser.Id)
return Forbid();
return Ok(invoice);
}
Use UUIDs to avoid enumeration attacks (sequential IDs let attackers scrape /orders/1, /orders/2, ...), but never as the sole access-control mechanism.
5. Hand-Rolling UUID Generation and Getting the Version/Variant Bits Wrong
The mechanics
A valid RFC 9562 UUID reserves specific bits:
- 4 bits for the version (bits 48–51 of the 128-bit layout)
- 2 bits for the variant (top 2 bits of the 65th bit, forced to
10)
Developers occasionally build "UUID-like" identifiers by concatenating a timestamp and random bytes manually — without setting these fields — producing a value that looks like a UUID but fails validation in libraries that check the version/variant, or worse, silently collides with the reserved value ranges other systems assume are safe (e.g., assuming version 4 means "fully random" when your hand-rolled value isn't).
// Bad: hand-rolled, non-compliant "UUID"
function fakeUuid() {
const hex = () => Math.floor(Math.random() * 16).toString(16);
return Array.from({ length: 32 }, hex).join('').replace(
/(.{8})(.{4})(.{4})(.{4})(.{12})/, '$1-$2-$3-$4-$5'
);
// no version nibble set, no variant bits set, Math.random() is not
// cryptographically secure either — see mistake #6
}
The fix
Always use a spec-compliant library or built-in:
// Good — Node.js 19+ / modern browsers
const id = crypto.randomUUID(); // RFC 9562 UUIDv4, correct bits, CSPRNG-backed
// Good — .NET
Guid id = Guid.NewGuid(); // UUIDv4
Guid id7 = Guid.CreateVersion7(); // UUIDv7, .NET 9+
Never construct UUID byte layouts by hand unless you are implementing UUIDv8 for a genuinely custom, documented format — and even then, set the version/variant nibbles explicitly per spec.
6. Using a Weak Random Source
The mechanics
UUIDv4's collision resistance depends entirely on the quality of its randomness. Math.random() in JavaScript is not cryptographically secure — it's a fast PRNG with a small internal state, not designed to resist prediction. Using it to generate identifiers that need to be unguessable (session tokens, password reset links, API keys formatted as UUIDs) is a real vulnerability, not a theoretical one.
| Random Source | Cryptographically Secure | Safe for Security Tokens |
|---|---|---|
Math.random() (JS) |
❌ | ❌ |
crypto.randomUUID() (JS) |
✅ | ✅ |
Guid.NewGuid() (.NET) |
✅ | ✅ |
uuid.uuid4() (Python) |
✅ (uses os.urandom) |
✅ |
Custom PRNG / rand() (C) |
❌ (typically) | ❌ |
The fix
Always use your platform's built-in, CSPRNG-backed UUID generator. Never build one on top of a general-purpose PRNG.
7. Comparing UUIDs Case-Sensitively (or Inconsistently)
The mechanics
RFC 9562 UUIDs are canonically lowercase, but plenty of systems emit uppercase (ToString("D").ToUpper(), some legacy Windows APIs, some JSON serializers). If your application stores one casing and compares against another using a case-sensitive collation or string equality check, "identical" UUIDs will silently fail to match.
// Bug: case mismatch silently fails
string incoming = "550E8400-E29B-41D4-A716-446655440000";
string stored = "550e8400-e29b-41d4-a716-446655440000";
bool matches = incoming == stored; // false! but they're the same UUID
The fix
// Compare as Guid, not as string — Guid.Equals is case-insensitive by design
bool matches = Guid.Parse(incoming) == Guid.Parse(stored); // true
-- If you must store as a string, normalize on write and use a
-- case-insensitive collation, or better, use a native GUID/BINARY type
-- so the question doesn't arise at all.
8. Ignoring Clock Behavior in Time-Based Versions (v1, v6, v7)
The mechanics
Time-based UUID versions embed a timestamp. If the underlying clock jumps backward (NTP correction, VM migration, container restart with a skewed clock) or if you generate a burst of UUIDs within the same millisecond, naive implementations can produce values that are out of order or, in the worst case for v1/v6 with poorly implemented clock sequences, collide.
RFC 9562 addresses this for UUIDv7 by allowing implementations to use sub-millisecond precision or a monotonic counter in the random bits, but not every library does this correctly.
The fix
- Prefer a well-maintained, spec-compliant library for UUIDv7 generation (or your runtime's built-in, e.g.
Guid.CreateVersion7()in .NET 9+) rather than a hand-rolled implementation. - Don't assume strict global monotonicity across distributed nodes with unsynchronized clocks — UUIDv7 gives you approximate time-ordering (good enough for index locality), not a guaranteed distributed sequence number. If you need a hard ordering guarantee, use a dedicated sequence generator (e.g. Snowflake-style IDs) instead.
Comparing the Alternatives
| Identifier Scheme | Sortable | Size | Collision Resistance | Best Fit |
|---|---|---|---|---|
BIGINT IDENTITY |
✅ | 8 bytes | N/A (centralized) | Single-writer relational tables |
| UUIDv4 | ❌ | 16 bytes | Extremely high (random) | Distributed, non-indexed or non-PK identifiers |
| UUIDv7 | ✅ | 16 bytes | High (random + time) | Distributed systems needing DB-friendly PKs |
| Twitter Snowflake / similar | ✅ | 8 bytes | High (structured) | High-throughput distributed systems |
| NanoID | ❌ | ~21 chars | High (configurable alphabet) | Short, URL-friendly public identifiers |
Summary
Most UUID mistakes come from treating UUIDs as an opaque, interchangeable "unique string" rather than a structured 128-bit value with real performance and security characteristics attached to how it's generated and stored:
- Match the UUID version to the job — v4 for opacity, v7 for database-friendly ordering.
- Store UUIDs in their native/binary form, not as padded strings.
- Don't assume string sort order equals chronological order, especially on SQL Server.
- UUIDs are not an authorization mechanism.
- Never hand-roll UUID generation — use spec-compliant, CSPRNG-backed libraries.
- Compare UUIDs as typed values, not raw strings, to avoid casing bugs.
- Understand that time-based versions offer approximate, not guaranteed, global ordering.
None of these are exotic edge cases — they're the mistakes that show up in code review, in production incident reports, and in "why is our index so fragmented" tickets across almost every team that adopts UUIDs without reading the spec first.