4096x more precision in GUID v7
2026-03-24
Tags: .NET
In .NET, Guid.CreateVersion7() ignores the optional sub-millisecond precision allowed for by RFC 9562. This post details how to increase the maximum precision from 1ms to 2.44140625ns, maintaining a more fine-grained order within the same millisecond. And, of course, with zero allocations to the managed heap.
A note on entropy
RFC 9562 §6.9 states that "Implementations SHOULD utilize a cryptographically secure pseudorandom number generator".
Some investigation into the file src/runtime/src/libraries/System.Private.CoreLib/src/System/Guid.cs in the .NET monorepo shows that under the hood, Guid.CreateVersion7() wraps Guid.NewGuid(), which farms out responsibility for the CSPRNG to the system.
To keep this security, we'll wrap and modify the existing GUID factory methods, instead of populating the random bits ourslves.
Setting the millisecond-precision timestamp
The first point of business is encoding the timestamp with millisecond-level precision.
With .NET 9.0 and greater, we can farm this out to the existing CreateVersion7().
Span<byte> bytes = stackalloc byte[16];
_ = Guid.CreateVersion7(d).TryWriteBytes(bytes);
Note
We don't expect TryWriteBytes() to fail here. As of time of writing, the only reason it would fail in any version of .NET is if bytes didn't have enough space, but we know that Guid.NewGuid() and Guid.CreateVersion7() will always be exactly 16 bytes.
If using between .NET Core 2.1 and .NET 8.x, or .NET Standard 2.1, this has to be done manually. The below example demonstrates the appropriate mapping in case you're subject to this limitation.
Span<byte> bytes = stackalloc byte[16];
_ = Guid.NewGuid().TryWriteBytes(bytes);
long totalMilliseconds = d.ToUnixTimeMilliseconds();
bytes[3] = (byte)(totalMilliseconds >> 0x28);
bytes[2] = (byte)(totalMilliseconds >> 0x20);
bytes[1] = (byte)(totalMilliseconds >> 0x18);
bytes[0] = (byte)(totalMilliseconds >> 0x10);
bytes[5] = (byte)(totalMilliseconds >> 0x08);
bytes[4] = (byte)(totalMilliseconds);
All other versions (.NET Core ≤ 2.0, .NET Standard ≤ 2.0, .NET Framework) also don't support Guid.TryWriteBytes(). Instead, use .ToByteArray() instead, which will cause a heap allocation of 2352 bytes.
byte[] bytes = Guid.NewGuid().ToByteArray();
long totalMilliseconds = d.ToUnixTimeMilliseconds();
bytes[3] = (byte)(totalMilliseconds >> 0x28);
bytes[2] = (byte)(totalMilliseconds >> 0x20);
bytes[1] = (byte)(totalMilliseconds >> 0x18);
bytes[0] = (byte)(totalMilliseconds >> 0x10);
bytes[5] = (byte)(totalMilliseconds >> 0x08);
bytes[4] = (byte)(totalMilliseconds);
Setting the fractional component
Next up, we need to compute the fraction of a millisecond that the remaining ticks represent. While there are 10,000 ticks in a millisecond, GUID v7 has a maximum precision of 1/4096 of a millisecond (equal to 2.44140625ns).
Mathematically speaking, the denominator of this fraction is 4096, and the numerator is defined as:
In code, we can express that like so:
int fracNumerator = (int) Math.Min((d.Ticks % 10000 * 4096 + 5000) / 10_000, 4095);
Then, we can update our Guid like so:
bytes[6] = (byte)(fracNumerator & 0xFF);
bytes[7] = (byte)((fracNumerator >> 8) & 0x0F);
Repairing the version
Finally, since we've just overwritten the 8th byte (which is shared between the version number and the fractional component), we need to put its first nibble back to 7. We can do this with some simple bit logic.
bytes[7] = (byte)((bytes[7] & 0x0F) | 0x70);
Complete method
Putting all this together, we can create a method for creating a GUID v7 that's about 500x more precise than the standard implementation in .NET 9:
static Guid CreateVersion7Precise(DateTimeOffset d)
{
#if NET9_0_OR_GREATER
Span<byte> bytes = stackalloc byte[16];
Guid.CreateVersion7(d).TryWriteBytes(bytes);
#elif NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1
Span<byte> bytes = stackalloc byte[16];
Guid.NewGuid().TryWriteBytes(bytes);
long totalMilliseconds = d.ToUnixTimeMilliseconds();
bytes[3] = (byte)(totalMilliseconds >> 0x28);
bytes[2] = (byte)(totalMilliseconds >> 0x20);
bytes[1] = (byte)(totalMilliseconds >> 0x18);
bytes[0] = (byte)(totalMilliseconds >> 0x10);
bytes[5] = (byte)(totalMilliseconds >> 0x08);
bytes[4] = (byte)(totalMilliseconds);
#else
byte[] bytes = Guid.NewGuid().ToByteArray();
long totalMilliseconds = d.ToUnixTimeMilliseconds();
bytes[3] = (byte)(totalMilliseconds >> 0x28);
bytes[2] = (byte)(totalMilliseconds >> 0x20);
bytes[1] = (byte)(totalMilliseconds >> 0x18);
bytes[0] = (byte)(totalMilliseconds >> 0x10);
bytes[5] = (byte)(totalMilliseconds >> 0x08);
bytes[4] = (byte)(totalMilliseconds);
#endif
int fracNumerator = (int) Math.Min((d.Ticks % 10000 * 4096 + 5000) / 10_000, 4095);
bytes[6] = (byte)(fracNumerator & 0xFF);
bytes[7] = (byte)((fracNumerator >> 8) & 0x0F);
bytes[7] = (byte)((bytes[7] & 0x0F) | 0x70);
return new Guid(bytes);
}
Testing consideration
Since GUID v7 can't express the full range of ticks, this method has a margin of error of \(\pm2\) ticks, or \(\pm200ns\).When testing, the success condition is:
Or, in code:
Math.Abs(left.Ticks - right.Ticks) <= 2;