Skip to content

Instantly share code, notes, and snippets.

@sdrapkin
Last active August 21, 2026 11:56
Show Gist options
  • Select an option

  • Save sdrapkin/03b13a9f7ba80afe62c3308b91c943ed to your computer and use it in GitHub Desktop.

Select an option

Save sdrapkin/03b13a9f7ba80afe62c3308b91c943ed to your computer and use it in GitHub Desktop.
Avoid using Guid.CreateVersion7 in .NET

.NET: Avoid using Guid.CreateVersion7

TL;DR: Guid.CreateVersion7 in .NET 9+ claims RFC 9562 compliance but violates its big-endian requirement for binary storage. This causes the same database index fragmentation that v7 UUIDs were designed to prevent. Testing with 100K PostgreSQL inserts shows rampant fragmentation (35% larger indexes) versus properly-implemented sequential GUIDs.

Guid.CreateVersion7 method was introduced in .NET 9 and is now included for the first time in a long-term-supported .NET 10. Microsoft docs for Guid.CreateVersion7 state “Creates a new Guid according to RFC 9562, following the Version 7 format.” We will see about that.

RFC 9562

RFC 9562 defines a UUID as a 128-bit/16-byte long structure (which System.Guid is, so far so good). RFC 9562 requires UUIDv7 versions to store a 48-bit/6-byte big-endian Unix timestamp in milliseconds in the most significant 48 bits. Guid.CreateVersion7 does not do that, and hence violates its RFC 9562 claims.

RFC 9562 UUIDv7 Expected Byte Order:
┌─────────────────┬──────────────────────┐
│  MSB first:     │                      │
│  Timestamp (6)  │  Mostly Random (10)  │
└─────────────────┴──────────────────────┘

Let’s test it out:

// helper structures
Span<byte> bytes8 = stackalloc byte[8];
Span<byte> bytes16 = stackalloc byte[16];

var ts = DateTimeOffset.UtcNow; // get UTC timestamp
long ts_ms = ts.ToUnixTimeMilliseconds(); // get Unix milliseconds
ts_ms.Dump(); // print out ts_ms - for example: 1762550326422

// convert ts_ms to 8 bytes
Unsafe.WriteUnaligned(ref bytes8[0], ts_ms);

// print the hex bytes of ts_ms, for example: 96-A4-2F-60-9A-01-00-00
BitConverter.ToString(bytes8.ToArray()).Dump();

// We now expect that Guid.CreateVersion7() will start with the above 6 bytes in reverse order:
// specifically: 01-9A-60-2F-A4-96 followed by 10 more bytes

var uuid_v7 = Guid.CreateVersion7(ts); // creating v7 version from previously generated timestamp
BitConverter.ToString(uuid_v7.ToByteArray()).Dump(); // print the .ToByteArray() conversion of uuid_v7

// Print out the 16 in-memory uuid_v7 bytes directly, without any helper conversions:
Unsafe.WriteUnaligned(ref bytes16[0], uuid_v7);
BitConverter.ToString(bytes16.ToArray()).Dump();

// Output (2 lines):
// 2F-60-9A-01-96-A4-2C-7E-8B-BF-68-FB-69-1C-A8-03
// 2F-60-9A-01-96-A4-2C-7E-8B-BF-68-FB-69-1C-A8-03

// 1. We see that both in-memory and .ToByteArray() bytes are identical.
// 2. We see that the byte order is *NOT* what we expected above,
//    and does not match RFC 9562 v7-required byte order.

// Expected big-endian: 01-9A-60-2F-A4-96-...
// Actual in-memory:    2F-60-9A-01-96-A4-...
// ❌ First 6 bytes are NOT in big-endian order

uuid_v7.ToString().Dump(); // 019a602f-a496-7e2c-8bbf-68fb691ca803
// The string representation of uuid_v7 does match the expected left-to-right byte order.

Note that RFC 9562 is first and foremost a byte-order specification. The .NET implementation of Guid.CreateVersion7 does not store the timestamp in big-endian order - neither in-memory nor in the result of .ToByteArray().

The .NET implementation instead makes the v7 string representation of the Guid appear correct by storing the underlying bytes in (v7-incorrect) non-big-endian way. However, this string "correctness" is mostly useless, since storing UUIDs as strings is an anti-pattern (RFC 9562: "where feasible, UUIDs SHOULD be stored within database applications as the underlying 128-bit binary value").

Also note that this problem is unrelated to RFC 9562 Section 6.2 which deals with optional monotonicity in cases of multiple UUIDs generated within the same Unix timestamp.

Who cares? Why this matters

This issue is not just a technicality or a minor documentation omission. The primary purpose of Version 7 UUIDs is to create sequentially ordered IDs that can be used as database keys (e.g., PostgreSQL) to prevent index fragmentation.

Databases sort UUIDs based on their 16-byte order, and the .NET implementation of Guid.CreateVersion7 fails to provide the correct big-endian sequential ordering over the first 6 bytes. As implemented, Guid.CreateVersion7 increments its first byte roughly every minute, wrapping around after ~4.27 hours. This improper behavior leads to the exact database fragmentation that Version 7 UUIDs were designed to prevent.

The only thing worse than a "lack of sequential-GUID support in .NET" is Microsoft-blessed supposedly trustworthy implementation that does not deliver. Let's see this failure in action. Npgsql is a de facto standard OSS .NET client for PostgreSQL, with 3.6k stars on Github. Npgsql v10 added Guid.CreateVersion7 as the implementation of NpgsqlSequentialGuidValueGenerator more than a year ago.

We'll test PostgreSQL 18 by inserting 100_000 UUIDs as primary keys using the following UUID-creation strategies:

  1. uuid = Guid.NewGuid(); which is mostly random, and we expect lots of fragmentation (no surprises).
  2. uuid = Guid.CreateVersion7(); which is supposedly big-endian ordered on 6 first bytes, and should reduce fragmentation.
  3. uuid = instance of NpgsqlSequentialGuidValueGenerator.Next(); which is identical to #2 (just making sure).
  4. uuid = FastGuid.NewPostgreSqlGuid(); from FastGuid, which not only reduces fragmentation, but is also very fast (see benchmarks).
-- PostgreSQL:
-- DROP TABLE IF EXISTS public.my_table; 
CREATE TABLE IF NOT EXISTS public.my_table 
( 
    id uuid NOT NULL, 
    name text, 
    CONSTRAINT my_table_pkey PRIMARY KEY (id) 
)

c# code to populate the above table:

async Task Main()
{
	string connectionString = "Host=localhost;Port=5432;Username=postgres;Password=postgres;Database=testdb";

	using var connection = new NpgsqlConnection(connectionString);

	if (true)
	{
		const int N_GUIDS = 100_000;
		var guids = new Guid[N_GUIDS];

		var entityFrameworkCore = new Npgsql.EntityFrameworkCore.PostgreSQL.ValueGeneration.NpgsqlSequentialGuidValueGenerator();

		for (int i = 0; i < guids.Length; ++i)
		{
			//guids[i] = Guid.NewGuid();
			//guids[i] = Guid.CreateVersion7();
			//guids[i] = SecurityDriven.FastGuid.NewPostgreSqlGuid();
			guids[i] = entityFrameworkCore.Next(null);
		}

		for (int i = 0; i < guids.Length; ++i)
		{
			using var conn = new NpgsqlConnection(connectionString);
			conn.Open();
			using var comm = new NpgsqlCommand($"INSERT INTO public.my_table(id, name) VALUES(@id, @name);", conn);

			var p_id = comm.Parameters.Add("@id", NpgsqlTypes.NpgsqlDbType.Uuid);
			p_id.Value = guids[i];

			var p_name = comm.Parameters.Add("@name", NpgsqlTypes.NpgsqlDbType.Integer);
			p_name.Value = i;

			comm.ExecuteScalar();
		}
	}

	using var conn2 = new NpgsqlConnection(connectionString);
	conn2.Open();
	using var command = new NpgsqlCommand("SELECT * FROM public.my_table ORDER BY id ASC LIMIT 100", conn2);

	using var reader = await command.ExecuteReaderAsync();
	while (reader.Read()) // Iterate through the results and display table details
	{
		// Fetch column values by index or column name
		Guid id = reader.GetGuid(0);
		string name = reader.GetString(1);

		// Display the information (using Dump for LINQPad or Console.WriteLine for other environments)
		$@"{id,-50} [{name}]".Dump();
	}
}//main

We'll run the database inserts and then check fragmentation via:

SELECT * FROM pgstattuple('my_table_pkey');

Case-1: using Guid.NewGuid() (no surprises) ↓

image

After VACUUM FULL my_table;: image

Case-2: using Guid.CreateVersion7()

image

After VACUUM FULL my_table;: image

Case-3: using NpgsqlSequentialGuidValueGenerator.Next(); (should be identical to #2) ↓

image

After VACUUM FULL my_table;: image

Case-4: using FastGuid.NewPostgreSqlGuid();

image

After VACUUM FULL my_table;: image

Understanding the results:

  • table_len is total physical size (in bytes) of the index file on disk.
  • tuple_percent is percentage of the index file used by live tuples. This is roughly equivalent to page density.
  • free_space is the total amount of unused space within the allocated pages.
  • free_percent is free_space as a percentage (free_space / table_len).

Note that tuple_percent and free_percent do not add up to 100% because ~15% of this index is occupied by internal metadata (page headers, item pointers, padding, etc).

Key observations:

  • In Cases-1/2/3 the database size (and #pages) was ~35% higher than for Case-4.
  • In Case-4 the page density was optimal (ie. VACUUM FULL had no effect).
  • Cases-2/3 (which use Guid.CreateVersion7) were virtually identical to Case-1 (which used a random Guid). Using Guid.CreateVersion7 showed zero improvement over random Guid.NewGuid().

Findings: Cases 1-3 produce identical fragmentation patterns (before and after VACUUM). Guid.CreateVersion7 provides zero benefit over random GUIDs. FastGuid requires no VACUUM as insertions are already optimal.

Microsoft's perspective

This issue was already raised and discussed with Microsoft in January 2025. Microsoft's implementation of Guid.CreateVersion7 is intentional and by design. They will not be changing the byte-order behavior or updating the documentation.

Summary and Conclusion

Problem:

Microsoft's Guid.CreateVersion7 (introduced in .NET 9) claims to implement RFC 9562's Version 7 UUID specification, but it violates the core big-endian byte-order requirement, which causes real database performance problems:

  • 35% larger indexes compared to properly-implemented sequential identifiers
  • 20% worse page density
  • Zero improvement over Guid.NewGuid() for preventing fragmentation

The irony: Version 7 UUIDs were specifically designed to prevent the exact fragmentation that Guid.CreateVersion7 still causes. Millions of developers will be tempted to use it, believe they are solving fragmentation, and actually be making it just as bad as with random identifiers, all while burning CPU to generate a "sequential" ID that isn't.

Solution:

  • Step-1: Avoid using Guid.CreateVersion7 for 16-byte database identifiers.
  • Step-2: Fix your database fragmentation with FastGuid: a lightweight, high-performance library that generates sequential 16-byte identifiers specifically optimized for database use.
    • .NewPostgreSqlGuid for PostgreSQL
    • .NewSqlServerGuid for SQL Server

Disclosure: I'm the author of FastGuid. This article presents reproducible benchmarks with verifiable results.

@DPDmancul

Copy link
Copy Markdown

@MarkPflug I was not responding to your comment, but to this very gist (first post above, made by @sdrapkin), which refers Npgsql and not SqlServer.
I was simply saying the problem is not the internal endianness used to repsent the uuids but the fact that in the original test above the uuids are generated in the same timestamp and so they are not guarantted to be monotonically increasing, by definition of uuid v7.
I am sad even generating uuids not too close doesn't solve for SqlServer: I didn't try with it beacuse I use only Postgres. I hope there will be found a solution also for SqlServer, as I think it is unlikily a flaw of CreateVersion7 implementation.

@sebastienros

Copy link
Copy Markdown

npgsql already stores UUIDv7 in big-endian order — the fragmentation comes from elsewhere

I reproduced this end-to-end against PostgreSQL 18 using npgsql's actual GuidUuidConverter, and the headline conclusion ("npgsql stores the binary representation in non-big-endian order") doesn't hold. npgsql's converter does not use Guid.ToByteArray(); it serializes with bigEndian: true:

public override void Write(PgWriter writer, Guid value)
{
    Span<byte> bytes = stackalloc byte[16];
    value.TryWriteBytes(bytes, bigEndian: true, out _);   // RFC 9562 order
    writer.WriteBytes(bytes);
}

Round-tripping 019efb2c-66ae-7e7e-a188-8ed475796556 through npgsql, asking PostgreSQL itself what it stored (encode(uuid_send(id),'hex')):

PG stored bytes       : 019efb2c66ae7e7ea1888ed475796556   <- timestamp big-endian, MSB first
.NET ToByteArray(true) : 019efb2c66ae7e7ea1888ed475796556   <- identical
.NET ToByteArray()     : 2cfb9e01ae667e7ea1888ed475796556   <- the layout the gist measured (npgsql never sends this)

The gist's "not big-endian" proof inspects ToByteArray() / the in-memory layout, but that is not what goes on the wire. PostgreSQL receives the timestamp big-endian and sorts by it correctly.

Where the fragmentation actually comes from

It's intra-millisecond non-monotonicity, not byte order. Guid.CreateVersion7() fills everything after the 48-bit timestamp with pure randomness — it does not implement the optional RFC 9562 §6.2 counter. So UUIDs generated within the same millisecond sort randomly relative to each other, causing B-tree page splits.

Decisive experiment — 100K rows inserted through the same npgsql path, only the generation pattern changes:

Strategy index_pages avg_leaf_density
Guid.NewGuid() (random) 514 67.8%
Guid.CreateVersion7() — tight loop (burst) 525 66.4%
Guid.CreateVersion7(ts) — advancing ms 388 89.8%
Monotonic v7 (per-ms counter) 388 89.8%

Row 3 packs optimally (~35% smaller — exactly the bloat figure in the gist) using the same converter. If npgsql's byte order were wrong, row 3 would fragment like row 1. It doesn't. The only thing that fragments is burst generation within a millisecond (row 2).

A pure-.NET check isolates it further: comparing consecutive UUIDs across distinct milliseconds, npgsql's big-endian bytes are 0 / 100,000 out of order — perfectly sequential.

How the benchmark conditions amplify the problem

The benchmark maximizes same-millisecond collisions, which is the worst case for CreateVersion7's missing counter:

  1. Tight generation loopfor (i…) guids[i] = Guid.CreateVersion7(); produces ~100K values in a few ms. That's tens of thousands of UUIDs sharing each timestamp, so almost the entire key is random → behaves like NewGuid() (rows 1 and 2 above are statistically identical).
  2. No time spread between keys — in a real workload inserts arrive over seconds/minutes, so most rows land in different milliseconds and append in order (row 3 behavior). The benchmark removes that spread entirely, turning a rare same-ms case into the common case.
  3. The diagnostic uses ToByteArray() — the default mixed-endian layout, which isn't npgsql's wire format. This is what led to attributing the bloat to byte order rather than to the generator.

Takeaway

  • npgsql's GuidUuidConverter is already RFC 9562-correct and needs no change; changing it would break round-tripping and ordering.
  • The fragmentation is a generator concern. Use a UUIDv7 generator that is monotonic within a millisecond (RFC 9562 §6.2 counter) — e.g. update NpgsqlSequentialGuidValueGenerator or use FastGuid. npgsql stores those bytes verbatim and sequentially.

This analysis and reproduction were generated by Claude Opus.

@sdrapkin

Copy link
Copy Markdown
Author

@sebastienros While the details you've presented are correct (and similar argument has already been presented by DPDmancul, so you're not really adding anything new) – I disagree with your conclusions/takeaway.

The proper conclusions:

  1. "Tight generation loop" is not some coding aberration - it is how real-world code looks like. As you stated yourself, Microsoft's Guid.CreateVersion7() in PK db-insertion context behaves identically to Guid.NewGuid() (i.e. poorly), which is contrary to what every .NET developer expects from Guid.CreateVersion7().
  2. Given (1), the headline statement "Avoid using Guid.CreateVersion7()" is as valid today as it was valid in 2025.
  3. The biggest offense still being committed by Microsoft is improper documentation. There is nothing in the docs for Guid.CreateVersion7() that mentions the need to pass true into guidVal.ToByteArray(true) or into guidVal.TryWriteBytes(dest, true, bytesWritten). The documentation for bool bigEndian parameter in both of these methods literally says bigEndian: Boolean, and that's it. Gee, thanks Microsoft for that profound enlightenment - all .NET devs will surely understand what to expect - except see (1).

Unrelated, but informative:
FastGuid has since added RFC 9562 Compliant UUIDv7 implementation (similar to Microsoft's implementation), which is ~3x faster than Guid.CreateVersion7().

@roji

roji commented Jun 25, 2026

Copy link
Copy Markdown

Shay here, one of the Npgsql maintainers; thanks @sebastienros for pinging me on this.

Tight generation loop" is not some coding aberration - it is how real-world code looks like.

It really is not, at least not how common real-world code looks like.

  • There certainly are scenarios where a large number of rows are inserted within the same millisecond with client-generated Guids, e.g. bulk import; but I certainly wouldn't say it's the common scenario. It's also a very good idea to do do VACUUM ANALYZE (or just VACUUM FULL) after a bulk import so that table statistics are updated and the planner can be aware of the new rows. In short, anyone doing significant bulk import who cares about performance should already be calling VACUUM.
  • In most other scenarios, people are not importing large numbers of rows within the same millisecond. Your benchmark targets a very specific, niche scenario and makes it seem mainstream/universal.
  • Another point missing above is that even if .NET-generated GUIDs had sub-miliisecond monotonicity, that still wouldn't help in concurrent situations: different clients would be monotonic, but rows inserted within the same millisecond into the database would not be (as they're coming from different clients). For cases like this, there's nothing .NET (or any client-side thing) can do - the only answer would be to generate the GUID in the database. So to summarize, the problem you're raising only manifests in non-concurrent single-client scenarios where large numbers of rows are inserted within the same millisecond.

Importantly, most of your assertions in the original post are misleading or false. Your OP presents an Npgsql/PostgreSQL benchmark as alleged proof that .NET's Guid.CreateGuid7() doesn't conform to the RFC, is problematic in terms of its byte ordering (big vs. little endianness), etc.. As just discussed, that's simply not the case; the only problem here (as far as I can tell) is the intra-millisecond non-monotonicity - your OP talks about a lot of other things, but doesn't mention that at all, even though it's the only thing happening here.

So I'd suggest at least very least adding a visible note explaining what's what and taking back some of your statements; because you are misleading people into thinking that serialization/byte ordering is somehow the cause of performance problem with Npgsql/PostgreSQL (it is not), and that the everyone should generally "Avoid using Guid.CreateVersion7()" because of performance problems (whereas the only issues occur if they insert large numbers of intra-millisecond rows into a database in a non-concurrent environment, a far more restricted scenario).

BTW I'll point out that Npgsql does use ToByteArray(bool) to serialize to big-endian encoding. Importantly, it's quite rare for the end developer to need to serialize a GUID to a binary representation; that's a low-level task done by e.g. the database driver, not by actual users of Npgsql. Anyone working at this level is expected to be very familiar with serialization details such as big vs. little endian. Note also that Guid is no different from int, where you also need to think about big vs. little endian (int serialization looks pretty much the same inside Npgsql, with us explicitly serializing to big endian).

Don't get me wrong, I think it would be a nice improvement if .NET Guid.CreateGuid7() were able to produce monotonic intra-millisecond GUIDs. But this conversation really has not been about that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment