posh.wiki


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:

\[ n = {{min(\lfloor{{({\Delta_{ticks}\cdot4096}) + 5000}\over10000}\rfloor, 4095)}} \] \[ n = {{min(\lfloor{{({\Delta_{ticks}\cdot4096}) + 5000}\over10000}\rfloor, 4095)}} \]

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\) \(\pm2\) ticks, or \(\pm200ns\) \(\pm200ns\) .When testing, the success condition is:

\[ |ticks_1 - ticks_2| \le 2 \] \[ |ticks_1 - ticks_2| \le 2 \]

Or, in code:

Math.Abs(left.Ticks - right.Ticks) <= 2;