DEV Community

Sukhpinder Singh
Sukhpinder Singh

Posted on

The GUID That Sorts Itself (Until It Doesn't)

I was looking at an index on a table whose primary key is a uniqueidentifier, and the fragmentation number was ugly. Every insert dropped a fresh Guid.NewGuid() into a random spot in the key range, so the B-tree was constantly splitting pages to make room in the middle. Classic random-GUID problem. I'd read about Version 7 GUIDs fixing this, and .NET has Guid.CreateVersion7() now, so I figured I'd swap it in and move on.

Then I got curious about how far I could actually trust the sort. So I did the thing I always do: wrote the smallest program that would tell me, and ran it instead of guessing.

What v4 keys look like when you line them up

Here's the baseline. Make six ordinary GUIDs a couple milliseconds apart, print them in the order I made them, then sort them and ask if the sorted order matches:

var v4Keys = new List<Guid>();
for (int i = 0; i < 6; i++)
{
    v4Keys.Add(Guid.NewGuid());
    Thread.Sleep(2);
}

var v4Sorted = v4Keys.OrderBy(g => g).ToList();
Console.WriteLine($"Sorted == creation order? {v4Sorted.SequenceEqual(v4Keys)}");
Enter fullscreen mode Exit fullscreen mode
a23b61f1-613d-464a-a2e2-7ac3ccf522e0
ebd6f78a-afbc-42de-9450-cdee1416c9e9
3001f306-6a01-4556-b338-b3649735a280
dcf56d17-4dd3-4087-8409-9958e0e17f61
a06dcf69-ccc2-4642-b610-b21bfd05b99e
b4da4432-b310-46fc-877d-3bf6aa89de0d
Sorted == creation order? False
Enter fullscreen mode Exit fullscreen mode

No surprise. A v4 GUID is 122 random bits. The value you get has nothing to do with when you asked for it, which is exactly why it scatters across the index.

v7 keys carry the time in their front teeth

A Version 7 GUID puts a 48-bit Unix-millisecond timestamp in the high bits and fills the rest with randomness. Same loop, same two-millisecond gaps, swap the factory:

var v7Keys = new List<Guid>();
for (int i = 0; i < 6; i++)
{
    v7Keys.Add(Guid.CreateVersion7());
    Thread.Sleep(2);
}
Enter fullscreen mode Exit fullscreen mode
01a17719-db51-7dc3-b700-0ff42fff55da  @ 07:25:45.681
01a17719-db54-713b-adec-96911e0ab1fe  @ 07:25:45.684
01a17719-db56-7379-9292-eedae31b7e54  @ 07:25:45.686
01a17719-db58-764f-b15b-faa65bb7ff23  @ 07:25:45.688
01a17719-db5a-775e-a3b6-e12cee035baf  @ 07:25:45.690
01a17719-db5c-7722-8037-9f652b6883f6  @ 07:25:45.692
Sorted == creation order? True
Enter fullscreen mode Exit fullscreen mode

Look at the left edge of each one. The prefix climbs in lockstep with the clock, so sorting the bytes sorts by time. New keys land at the end of the range instead of the middle, and that's the whole point for a database: inserts append, pages stop splitting.

And that timestamp isn't a party trick, you can read it straight back out of the key. The byte layout is big-endian for the RFC, so I ask for the bytes in that order and pull the first six:

static DateTimeOffset ReadTimestamp(Guid guid)
{
    Span<byte> bytes = stackalloc byte[16];
    guid.TryWriteBytes(bytes, bigEndian: true, out _);
    Span<byte> ms = stackalloc byte[8];
    bytes[..6].CopyTo(ms[2..]);   // 48-bit value, right-aligned in a long
    long unixMs = BinaryPrimitives.ReadInt64BigEndian(ms);
    return DateTimeOffset.FromUnixTimeMilliseconds(unixMs);
}
Enter fullscreen mode Exit fullscreen mode

That bigEndian: true matters. Guid stores its first three fields in little-endian internally, so the default ToByteArray() gives you a jumbled prefix and the timestamp comes out as nonsense. The overload that takes the flag is the one you want.

The part the blog posts skip

Here's where I stopped trusting the word "sortable" and made it earn it. Those sleeps above are doing a lot of work, each key got its own millisecond. Real inserts don't wait politely. So I generated a hundred thousand of them as fast as the loop would go and asked the same question:

const int burst = 100_000;
var sameMs = new Guid[burst];
for (int i = 0; i < burst; i++)
    sameMs[i] = Guid.CreateVersion7();

var byValue = sameMs.OrderBy(g => g).ToArray();
bool matches = byValue.AsSpan().SequenceEqual(sameMs);
Enter fullscreen mode Exit fullscreen mode
Burst of 100,000 v7 GUIDs took 71.8 ms
  spanned 55 distinct millisecond bucket(s)
  sorting recovers exact creation order? False
Enter fullscreen mode Exit fullscreen mode

False. A hundred thousand keys fell into 55 millisecond buckets, so roughly eighteen hundred of them share each bucket, and inside a bucket the ordering bits are random. Sorting gets you to the right millisecond and then shrugs. I printed a pair to see it plainly:

Same millisecond, created a then b, but b < a byte-wise:
  a (first)  01a17719-db60-79bc-84dd-1b62234a3abb
  b (second) 01a17719-db60-753f-90e6-9be4edb208b7
Enter fullscreen mode Exit fullscreen mode

Created a first, b second, same millisecond, and b sorts ahead of a. That's not a bug. .NET 10's implementation doesn't add a monotonic counter in the low bits, so two keys born in the same millisecond can come back in either order. (The spec leaves room for a counter; this runtime doesn't use one.)

This is a container, not a lab bench, and the 71.8 ms will be different on your box. The number I care about is 55 buckets, because it says the thing that matters: at insert speed, "sorted" means sorted to the millisecond, nothing finer.

So where I actually landed

For database keys, v7 is a straight upgrade over v4 and I'd reach for it by default now. The index locality is real, and getting a creation timestamp baked into the key for free is a nice bonus when you're squinting at rows in production. My one caution: don't let "time-ordered" slide into "ordered." If you need to know which of two rows was inserted first, and they might land in the same millisecond, you still need a real sequence or an identity column. The GUID won't settle that argument, and it'll look like it might, which is worse.

Want to see it on your own hardware? Here's the whole thing in one file, Program.cs, targeting net10.0 (it runs fine on .NET 9 too). No packages.

using System.Buffers.Binary;
using System.Diagnostics;

Console.WriteLine($"Running on .NET {Environment.Version}\n");

var v4Keys = new List<Guid>();
for (int i = 0; i < 6; i++) { v4Keys.Add(Guid.NewGuid()); Thread.Sleep(2); }
Console.WriteLine("v4 in creation order:");
foreach (var g in v4Keys) Console.WriteLine($"  {g}");
var v4Sorted = v4Keys.OrderBy(g => g).ToList();
Console.WriteLine($"  Sorted == creation order? {v4Sorted.SequenceEqual(v4Keys)}\n");

var v7Keys = new List<Guid>();
for (int i = 0; i < 6; i++) { v7Keys.Add(Guid.CreateVersion7()); Thread.Sleep(2); }
Console.WriteLine("v7 in creation order:");
foreach (var g in v7Keys) Console.WriteLine($"  {g}  @ {ReadTimestamp(g):HH:mm:ss.fff}");
var v7Sorted = v7Keys.OrderBy(g => g).ToList();
Console.WriteLine($"  Sorted == creation order? {v7Sorted.SequenceEqual(v7Keys)}\n");

const int burst = 100_000;
var sameMs = new Guid[burst];
var sw = Stopwatch.StartNew();
for (int i = 0; i < burst; i++) sameMs[i] = Guid.CreateVersion7();
sw.Stop();
int buckets = sameMs.Select(ReadTimestamp).Distinct().Count();
var byValue = sameMs.OrderBy(g => g).ToArray();
bool matches = byValue.AsSpan().SequenceEqual(sameMs);
Console.WriteLine($"Burst of {burst:N0} v7 GUIDs took {sw.Elapsed.TotalMilliseconds:F1} ms");
Console.WriteLine($"  spanned {buckets} distinct millisecond bucket(s)");
Console.WriteLine($"  sorting recovers exact creation order? {matches}\n");

for (int i = 1; i < burst; i++)
{
    if (ReadTimestamp(sameMs[i]) == ReadTimestamp(sameMs[i - 1]) &&
        sameMs[i].CompareTo(sameMs[i - 1]) < 0)
    {
        Console.WriteLine("Same millisecond, created a then b, but b < a byte-wise:");
        Console.WriteLine($"  a (first)  {sameMs[i - 1]}");
        Console.WriteLine($"  b (second) {sameMs[i]}");
        break;
    }
}

static DateTimeOffset ReadTimestamp(Guid guid)
{
    Span<byte> bytes = stackalloc byte[16];
    guid.TryWriteBytes(bytes, bigEndian: true, out _);
    Span<byte> ms = stackalloc byte[8];
    bytes[..6].CopyTo(ms[2..]);
    long unixMs = BinaryPrimitives.ReadInt64BigEndian(ms);
    return DateTimeOffset.FromUnixTimeMilliseconds(unixMs);
}
Enter fullscreen mode Exit fullscreen mode

Have you moved any tables over to v7 keys yet, or are you still sitting on sequential integers? Curious what the fragmentation looked like before and after if you have.

— still benchmarking things nobody asked me to measure

Top comments (0)