I moved a table's primary key off random GUIDs a little while back. The reason is the usual one: a clustered index hates random inserts. Every new row wants a spot in the middle of the index instead of the end, pages split, the thing fragments, and your inserts get slower the bigger the table gets. Version 7 GUIDs are the tidy fix. They carry a millisecond timestamp in their leading bytes, so new keys sort roughly in creation order and land near the end where the index wants them.

Before I trusted that in production I wanted to see the ordering with my own eyes. So I did the dumbest thing that would show it: generate a pile of keys and count how many came out in order. The answer made me feel a bit silly.

What v7 actually promises

A v7 GUID isn't random all the way down. The first 48 bits are a Unix millisecond timestamp, then a handful of version and variant bits, then the rest is random. Two keys created in different milliseconds sort by those timestamps. That's the whole trick, and it's why people reach for Guid.CreateVersion7() as a key: time-ordered values append instead of scattering.

One thing to pin down first. "Sort order" depends on who's doing the sorting. The UUIDv7 spec and databases like Postgres compare the raw bytes left to right. .NET's own Guid.CompareTo walks the GUID's internal fields instead, which isn't the same thing. So I measured both, because which one you get depends on where the keys end up living.

Here's the byte-order comparer:

static int CompareBytes(Guid a, Guid b) { Span<byte> ba = stackalloc byte[16]; Span<byte> bb = stackalloc byte[16]; a.TryWriteBytes(ba, bigEndian: true, out _); b.TryWriteBytes(bb, bigEndian: true, out _); return ba.SequenceCompareTo(bb); } 

The measurement

I generated a million keys of each kind in a loop and counted adjacent inversions: every spot where a key sorts before the one generated just ahead of it. A perfectly sequential source scores zero. A random source is a coin flip for each pair, so it sits around fifty percent.

static (int inversions, double pct) Inversions(Guid[] g, Comparison<Guid> cmp) { int inv = 0; for (int i = 1; i < g.Length; i++) if (cmp(g[i], g[i - 1]) < 0) inv++; return (inv, 100.0 * inv / (g.Length - 1)); } 

I expected v4 near fifty and v7 near zero. I got this:

Out-of-order adjacent pairs (lower = more sequential): byte order v4 50.0% v7 50.0% Guid.CompareTo v4 50.0% v7 50.0% 

Fifty percent for v7, in plain byte order. The time-ordered key was no more ordered than the random one. Both comparers agreed, which wasn't much comfort.

Why the timestamp didn't help

Millisecond resolution is the catch, and a loop generates keys far faster than one per millisecond. A million of them took around 400 ms on this machine, so roughly a couple thousand keys land in every single tick. I counted how many neighbours shared a millisecond:

v7 keys sharing a millisecond with their neighbour: 999,584 of 999,999 (100.0%) 

Every pair. And inside one millisecond, a v7 GUID is just random bits. .NET's implementation doesn't add a monotonic counter to keep same-tick keys in order, so those random tails sort against each other and you're back to a coin flip. The timestamp only breaks ties between milliseconds, and in a tight loop there are almost none of those.

So I slowed down to one key per millisecond, which is a lot closer to how rows actually show up in a real system, and ran it again:

for (int i = 0; i < M; i++) { spread[i] = Guid.CreateVersion7(); Thread.Sleep(1); } 
v7 spread one-per-ms, 3,000 keys: byte order 0.00% out of order Guid.CompareTo 0.00% out of order 

Zero, under both comparers. Give the timestamp room to move and v7 does exactly what it says on the tin. The ordering was never a lie; it just lives at millisecond resolution, and a benchmark loop runs underneath that.

What I took from it

The practical read is reassuring with one sharp edge. Real inserts are spread across requests over seconds and minutes, so your table gets the append-friendly ordering v7 is sold on, and the fragmentation problem it's meant to solve really does go away. I'm keeping v7 for new tables. But if you ever bulk-generate keys in a loop, seeding a database, running a migration, importing a batch, and expect them to come out sorted, they won't. A few thousand per millisecond is a few thousand coin flips, and the last-mile ordering you were counting on isn't there.

My honest opinion after this: treat the "sequential" label as "sequential at millisecond scale," not "monotonic." Those are different promises. If you genuinely need strictly increasing keys inside a burst, say an outbox you read back in insert order, don't lean on the GUID for it. Add a sequence or a timestamp column and sort on that.

One more caveat worth saying out loud, even though my short spread test didn't trip over it: not every database orders GUIDs by their bytes. SQL Server's uniqueidentifier sorts them in its own way, which has burned people who assumed v7 keys would cluster there the same as in Postgres. Check how your store compares them before you count on the ordering.

Here's the whole thing if you want to run it yourself. It needs the .NET 10 SDK, then dotnet run -c Release:

using System.Diagnostics; const int N = 1_000_000; static int CompareBytes(Guid a, Guid b) { Span<byte> ba = stackalloc byte[16]; Span<byte> bb = stackalloc byte[16]; a.TryWriteBytes(ba, bigEndian: true, out _); b.TryWriteBytes(bb, bigEndian: true, out _); return ba.SequenceCompareTo(bb); } static (int inversions, double pct) Inversions(Guid[] g, Comparison<Guid> cmp) { int inv = 0; for (int i = 1; i < g.Length; i++) if (cmp(g[i], g[i - 1]) < 0) inv++; return (inv, 100.0 * inv / (g.Length - 1)); } static Guid[] Generate(int n, Func<Guid> make) { var arr = new Guid[n]; var sw = Stopwatch.StartNew(); for (int i = 0; i < n; i++) arr[i] = make(); sw.Stop(); Console.WriteLine($" generated {n:N0} in {sw.ElapsedMilliseconds,4} ms"); return arr; } Console.WriteLine($"Tight loop, {N:N0} keys each\n"); Console.WriteLine("Guid.NewGuid() (v4, random):"); var v4 = Generate(N, Guid.NewGuid); Console.WriteLine("Guid.CreateVersion7() (v7, time-ordered):"); var v7 = Generate(N, Guid.CreateVersion7); Console.WriteLine(); var v4Bytes = Inversions(v4, CompareBytes); var v7Bytes = Inversions(v7, CompareBytes); var v4Net = Inversions(v4, (a, b) => a.CompareTo(b)); var v7Net = Inversions(v7, (a, b) => a.CompareTo(b)); Console.WriteLine("Out-of-order adjacent pairs (lower = more sequential):"); Console.WriteLine($" byte order v4 {v4Bytes.pct,5:F1}% v7 {v7Bytes.pct,5:F1}%"); Console.WriteLine($" Guid.CompareTo v4 {v4Net.pct,5:F1}% v7 {v7Net.pct,5:F1}%"); Console.WriteLine(); static long SameMsNeighbours(Guid[] g) { long ties = 0; for (int i = 1; i < g.Length; i++) if (UnixMs(g[i]) == UnixMs(g[i - 1])) ties++; return ties; static long UnixMs(Guid guid) { Span<byte> b = stackalloc byte[16]; guid.TryWriteBytes(b, bigEndian: true, out _); long ms = 0; for (int k = 0; k < 6; k++) ms = (ms << 8) | b[k]; return ms; } } long ties = SameMsNeighbours(v7); Console.WriteLine($"v7 keys sharing a millisecond with their neighbour: {ties:N0} of {N - 1:N0} ({100.0 * ties / (N - 1):F1}%)"); Console.WriteLine(); const int M = 3_000; var spread = new Guid[M]; for (int i = 0; i < M; i++) { spread[i] = Guid.CreateVersion7(); Thread.Sleep(1); } var spreadBytes = Inversions(spread, CompareBytes); var spreadNet = Inversions(spread, (a, b) => a.CompareTo(b)); Console.WriteLine($"v7 spread one-per-ms, {M:N0} keys:"); Console.WriteLine($" byte order {spreadBytes.pct,5:F2}% out of order"); Console.WriteLine($" Guid.CompareTo {spreadNet.pct,5:F2}% out of order"); 

Have you moved a table to v7 keys yet, or are you still on random GUIDs and a nightly defrag job? Curious whether the fragmentation actually eased up for you.

— still benchmarking things nobody asked me to benchmark