Skip to content

Instantly share code, notes, and snippets.

@sdrapkin
Last active December 9, 2025 20:23
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.

@tannergooding
Copy link

tannergooding commented Nov 12, 2025

Let’s test it out:

This is first and foremost misleading. While the majority of computers are little-endian and so this will reproduce, some machines remain big-endian and so it will not.

What you're doing here is directly observing the raw internal representation of the type using an Unsafe API. If you do unsafe things, unexpected behaviors may occur, that's in part why it is unsafe.

Note that RFC 9562 is first and foremost a byte-order specification.

This is a personal interpretation and is not something I think most would agree with. The RFC is a specification which defines a type and where most of the spec doesn't directly discuss the byte order. The spec then explicitly covers, at several points, that things may differ.

Lets start with section 1. Introduction:

This specification defines a Uniform Resource Name namespace for Universally Unique IDentifiers (UUIDs), also known as Globally Unique IDentifiers (GUIDs)

I'm starting here to avoid any later misgivings of GUID != UUID. The spec explicitly calls out that these are alternative names for the same thing.

Next, lets touch on 4. UUID Format:

In the absence of explicit application or presentation protocol specification to the contrary, each field is encoded with the most significant byte first (known as "network byte order").

Saving UUIDs to binary format is done by sequencing all fields in big-endian format. However, there is a known caveat that Microsoft's Component Object Model (COM) GUIDs leverage little-endian when saving GUIDs. The discussion of this (see [MS_COM_GUID]) is outside the scope of this specification.

So the RFC itself covers that application or protocols may differ which cause it to not be "network byte order" (big-endian) and additionally covers that saving to binary format involves sequencing fields as big-endian but that there is at least one well known format (Microsoft's GUID) which default's to little-endian.

.NET is intended to be "safe" by default and so using only safe APIs and without converting to a binary-format, the way a System.Guid is interpreted is as the RFC intends. That is, the bytes are ordered, compared, and displayed as if they were "big endian".

.NET then provides explicit APIs for converting to a binary format, such as ToByteArray() We also provide APIs for converting from a binary format, namely the constructors. The documentation for these then explicitly calls out that the byte array returned is different from the string-order, and so is explicitly documented as per the RFC guidance: https://learn.microsoft.com/en-us/dotnet/api/system.guid.tobytearray?view=net-9.0#system-guid-tobytearray

We then provide additional overloads of these APIs which allow the user to deviate and explicitly request it be serialized as bigEndian instead. There looks to possibly be an issue here where the docs are missing and that needs fixing. However, the general premise of it remains the same and the meaning of the parameter is fairly intuitive in spite of that, so it likely won't be an issue for most consumers.

The rest of the document then continues on this already incorrect path and makes further incorrect presumptions based on the bugs in the first sample.

@codekaizen
Copy link

In light of the clear response from @tannergooding above and in the bug report, it's hard to view this report as a good-faith effort to help developers and the dotnet ecosystem, rather, it appears to be biased given the author's investment in their own implementation of a GUID library. Ignoring the clear API given by System.Guid and instead exposing and comparing against internal representation, interpreting an RFC as specifying implementation instead of a contract (I can't think of any RFC that enforces a specific implementation - only contractual representations and behavior), and characterizing the "as design" response with an obviously negative tone approaching an ad hominem attack, together, to me, point to an agenda to promote your library. This could have been dealt with instead as an article highlighting the nuances of the provided GUID API and how to use it for maximum benefit in database scenarios.

@MarkPflug
Copy link

MarkPflug commented Nov 12, 2025

@tannergooding

I think the problem is that people want an API that can allocate GUIDs in a manner that would give monotonic ordering in a database in the same way that NEWSEQUENTIALGUID does, they were expecting this new CreateVersion7 method to provide that, but it does not. So, .NET is left without an API providing this functionality.

I've had to deal with this myself, in .NET framework code. We p-invoked UuidCreateSequential in rpcrt4.dll. We then had to rearrange the bytes in the same way that SqlGuid does. I only became aware that was necessary because I bothered to test the behavior at the database layer, and saw that it hadn't fixed the fragmentation issue. I would have really appreciated an API that did this for me.

I don't know if it makes sense for the .NET Guid implementation to be concerned with this problem though, so the fact that CreateVersion7 doesn't provide the expected behavior is probably fine. BUT! I think it's a huge mistake for the documentation to not call this out. The evidence is right here on this page, it is leading to user confusion.

My opinion is that the Guid ordering issue should be handled by the database providers (Microsoft.Data.SqlClient and Npgsql) themselves, via an AllocateSequentialGuid that is documented to provide the expected ordering for that specific database.

@tannergooding
Copy link

they were expecting this new CreateVersion7 method to provide that, but it does not.

@MarkPflug it does, and that has been covered multiple times.

https://www.rfc-editor.org/rfc/rfc9562.html covers at multiple points that there are differences between field encoding and saving to binary format. It covers that the latest version of the spec attempts to clarify and fix problems around this space from misunderstandings in the previous spec. It explicitly allows for deviations, particularly with documentation, which .NET does.

.NET is an OOP ecosystem which allows information hiding via encapsulation. In other words, we have private fields and that means the state is private, it cannot be guaranteed or strictly relied upon. Such private state can only be observed with unsafe code, which is "unsafe" (as the name implies) and can lead to undefined or other problematic behavior.

So not only do we document in several places that the field encoding differs, but such field encoding can only be accessed with unsafe code. Instead, we provide public APIs (like the new Version and Variant properties) to access the "field encoding" otherwise, abstracting away this nuance and ensuring users don't end up with wrong behavior.

Then when it comes to saving to binary format we provide explicit APIs for both storing and loading, which explicitly document that they deviate from the string representation and which explicitly provide overloads taking a bool bigEndian parameter allowing users to achieve the alternative behavior that matches what they may desire instead. -- This deviation is also something the RFC explicitly calls out as a possibility and which while it lists as "out of scope" of the spec, it does link to further documentation on the topic which covers it in more detail.

System.Guid itself then correctly compares, sorts, hashes, etc the underlying UUID represented correctly on all platforms and scenarios. It is always according to the RFC spec in terms of how the actual value should be interpreted, which is as if the binary format was big endian. That is, we correctly handle any internal field encoding nuance to ensure the interpretation is as a user would expect given the string returned. That is the same as for something like 0x1234 which is the same value and interpreted the same regardless of whether you are on a big-endian platform (encoded [0x12, 0x34]) or a little-endian platform (encoded [0x34, 0x12])

The only potential problem here is if some downstream consumer intended to store and/or load to binary format as one encoding and accidentally used the other encoding instead. There are some downstream consumers that have down this, but that is strictly a bug in their implementation and their own failure to write tests and read the documentation. The System.Guid type itself and all APIs it exposes remain correct, compliant, documented (minus the brand new ones where there looks to be a synchronization issue between C# source and the html pages), and doing everything correctly.

@MarkPflug
Copy link

@tannergooding
I think there must be some misunderstanding. I didn't mention RFCs, endianness, GUID versions, etc. I'm not talking about any of that. I'm talking about "an API that can be used to allocate Guids in the same manner as NEWSEQUENTIALGUID" in t-sql. That does not exist. The new CreateVersion7 method doesn't provide that. I'm not trying to pile on you here, I'm just trying to explain where our expectations aren't aligning.

Here is a complete example of what I (and I assume others) want:
https://gist.github.com/MarkPflug/ddcac942c4934a4f7492411bbb9f9e24

Lines 44/45:

//guidParam.Value = Guid.CreateVersion7();
guidParam.Value = Guid.NewSequentialGuid();

If you run the code with the NewSequentialGuid implementation, it reports SUCCESS, all the record were ordered as expected. If you run with CreateVersion7 it reports "FAILED", the records are returned in a different order than expected.

I believe the behavior of NewSequentialGuid is what people were expecting CreateVersion7 to provide. It does not.

@sdrapkin
Copy link
Author

The c# PostgreSQL-insertion code was updated to use parameterized SQL.
Note that the results did not change.

Using guids[i] = Guid.CreateVersion7();:
image

Using guids[i] = SecurityDriven.FastGuid.NewPostgreSqlGuid();:
image

@tannergooding
Copy link

tannergooding commented Nov 12, 2025

@MarkPflug, that won't fix the issue.

CreateVersion7 already creates "sequential" (ordered) IDs. -- Barring the nuance that ones created "within the same millisecond" are randomly ordered by default, which is an explicit best practice and security consideration. i.e. UUIDv7 defaults to only ms precision and 62 random trailing bits purposefully.

The problem here is that the user or database provider is taking the generated Guid and then serializing it as one endianness (likely little-endian). The database itself then expected and interprets it as the other endianness (typically big-endian), so despite the generated Guid being sequential, the database doesn't end up with the keys it sees being sequential.

The database providers or user are likely deserializing using the matching constructor (likely little-endian) so they see it "roundtrip", but because the database itself doesn't agree, and so that's where the problem is. That is, the database provider or user failed to also test that the consumers of the values they serialized saw them correctly.

The fix here is that the database providers or user should pass the inverse bigEndian flag. That is, if they are simply using guid.ToByteArray() or guid.TryWriteBytes(destination) today, they should instead be using guid.ToByteArray(bigEndian: true) or guid.TryWriteBytes(destination, bigEndian: true). They should likewise be using the corresponding constructor (i.e. new Guid(bytes, bigEndian: true)).

The whole problem here is entirely due to mismatched serialization. It's not a problem with Guid, it's not a problem with CreateVersion7, it wouldn't be solved by some NewSequentialGuid or any similar API. It is a bug in the serialization/deserialization code where the GUID/UUID values are not being properly stored as per the database expectations.


Now, npgsql might not be able to fix this bug trivially due to backcompat. In which case they'd need to provide some other NpgsqlDbType that "does the right thing". A user can also understand this NPGSQL bug (which should be documented on the NPSQL side) and manually workaround it by swapping the bytes themselves. But it remains that the bug here is in how they use the System.Guid serialization APIs incorrectly and mismatch what the underlying database itself expects.

@sdrapkin
Copy link
Author

sdrapkin commented Nov 12, 2025

@codekaizen I provided reproducible benchmarks with verifiable results (which nobody has refuted), yet you're accusing me of bias by providing no arguments at all. You are intentionally misinterpreting my code by claiming that I'm using "internal in-memory representation", which I've only showed to illustrate what is happening.

I'm using the following .NET-idiomatic code to add a row to a UUID-PK'ed PostgreSQL table:

var connString = "Host=localhost;Port=5432;Username=postgres;Password=postgres;...";
using var conn = new NpgsqlConnection(connString);
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 = Guid.CreateVersion7();
var p_name = comm.Parameters.Add("@name", NpgsqlTypes.NpgsqlDbType.Text);
p_name.Value = "blah";
comm.ExecuteScalar();

This idiomatic .NET code will cause the same PostgreSQL fragmentation as if you used Guid.NewGuid().

This could have been dealt with instead as an article highlighting the nuances of the provided GUID API and how to use it for maximum benefit in database scenarios.

I just did, I think you've read it. Perhaps you'd like to offer your version of how to use CreateVersion7 in the above short SqlClient c# code such that comm.ExecuteScalar() call does NOT create PostgreSQL fragmentation?

Perhaps @tannergooding would also like to show us his improved code as well?

@tannergooding
Copy link

tannergooding commented Nov 12, 2025

Perhaps @tannergooding would also like to show us his improved code as well?

@sdrapkin As indicated above, I covered that observing the raw bytes with Unsafe.WriteUnaligned from your first sample was unsafe (hence the name) and undefined behavior.

I also noted that if NpgsqlDbType.Uuid is taking a Guid.CreateVersion7() and storing it such that the UUID in the database doesn't match p_id.Value.ToString(), then the bug here is in Npgsql and how it is serializing the value. It is not, in any way, a problem with System.Guid or the APIs it exposes, but rather a bug in the database provider and incorrectly serializing (i.e. storing to binary format as its put in the RFC terms) a value.

That may not be trivial for the database provider (NPGSQL) to fix due to back-compat. However, it should document this historical bug and document or provide a workaround or alternative NpgsqlDbType so it isn't an issue in the future.

@MarkPflug
Copy link

The fix here is that the database providers or user should pass the inverse bigEndian flag.

@tannergooding, with all due respect, that is an absurd suggestion. It doesn't matter if what the database/client is doing is incorrect or "not compliant with the RFC". It's too late for that. That ship sailed decades ago for SqlClient. We live in a reality where things are broken sometimes.

I'm also not arguing that CreateVersion7 is broken. Version 7 guids are understood to be sequentially allocated. CreateVersion7 does this, correctly, but unfortunately it isn't compatible with broken database implementations. That deserves to be mentioned in the documentation. This issue is going to bite people, it doesn't matter if it's the fault of the .NET implementation or the database. Fortunate people are going to discover that it isn't compatible and fix it. Unfortunate people will use it, assuming it's doing the expected thing (which it isn't), which will lead to bad (potentially catastrophic) database performance.

it wouldn't be solved by some NewSequentialGuid or any similar API

I don't understand how you can say this, when my example NewSequentialGuid demonstrates the expected behavior. People were wanting/expecting an API to provide this behavior, but that's not what they got with CreateVersion7. That's fine. We have to handle the byte shuffling ourselves because sqlserver/sqlclient "got it wrong". The problem is that some people are expecting behavior from CreateVersion7 that it doesn't/can't provide, and the documentation should mention that.

@tannergooding
Copy link

A NewSequentialGuid which explicitly chooses to inverse the bits under the assumption something else (i.e. SqlClient, Npgsql, etc) will incorrectly serialize doesn't fix it. Rather it creates the same, but inverse, scenario. It isn't a fix, just a new way to expose the same problem and is likely to make everything worse.

Put another way... As it is today, every API exposed on System.Guid is correct. There being a real problem of downstream consumers getting it wrong is unfortunate, but it is also very trivial to explain and rationalize because the core libraries remain strictly valid/compliant so the bug is entirely downstream. Now, if we expose an intentionally incorrect API, then it simply adds to the confusion and we become part of the problem instead. For example, it means that a user calling NewSequentialGuid and then comparing the results in .NET itself will get incorrect results, it will display an incorrect string, it will display an incorrect variant/version, all of this will lead to further downstream issues. It will also break downstream consumers that were already doing the correct thing. In other words, if a downstream consumer was performing correct serialization/deserialization, they would now be broken when encountering something created from NewSequentialGuid.

The only "good" long term solution here is for the downstream consumers that are doing the wrong thing to document that they have a bug. They may not be able to fix this bug directly, but they can document their own issue. They can then look at providing tooling and configuration switches which might allow fixing existing databases or ensuring future entries are correctly handled. They could look at providing new types and soft-deprecating the existing incorrect types (i.e. deprecate NpgsqlDbType.Uuid and expose a new NpgsqlDbType.BetterUuid, but with an actually good name).. They have many options available, but it remains entirely on them.

.NET can further highlight that people need to be aware of passing in the correct bigEndian flag based on what the storage actually expects, but it will not solve the issue and is outside the control of most users. Users who know that a given database provider is broken (ideally because they document it) can write a trivial helper to "swap" the bytes as part of giving the value to the provider, but that will be very provider specific and requires careful consideration so they don't break downstream handling. It's something that System.Guid already provides all the necessary tools for/around, but where it cannot fix things for them.

@sdrapkin
Copy link
Author

sdrapkin commented Nov 12, 2025

The fix here is that the database providers or user should pass the inverse bigEndian flag.

@tannergooding, with all due respect, that is an absurd suggestion. It doesn't matter if what the database/client is doing is incorrect or "not compliant with the RFC". It's too late for that. That ship sailed decades ago for SqlClient. We live in a reality where things are broken sometimes.

I'm also not arguing that CreateVersion7 is broken. Version 7 guids are understood to be sequentially allocated. CreateVersion7 does this, correctly, but unfortunately it isn't compatible with broken database implementations. That deserves to be mentioned in the documentation. This issue is going to bite people, it doesn't matter if it's the fault of the .NET implementation or the database. Fortunate people are going to discover that it isn't compatible and fix it. Unfortunate people will use it, assuming it's doing the expected thing (which it isn't), which will lead to bad (potentially catastrophic) database performance.

it wouldn't be solved by some NewSequentialGuid or any similar API

I don't understand how you can say this, when my example NewSequentialGuid demonstrates the expected behavior. People were wanting/expecting an API to provide this behavior, but that's not what they got with CreateVersion7. That's fine. We have to handle the byte shuffling ourselves because sqlserver/sqlclient "got it wrong". The problem is that some people are expecting behavior from CreateVersion7 that it doesn't/can't provide, and the documentation should mention that.

@tannergooding firmly argues that CreateVersion7 is RFC-compliant. I firmly argue that it is not.
But let's take a moment to think about why UUIDv7 exists - what is its purpose?

UUIDv7 is a time-ordered identifier designed to offer the global uniqueness of traditional mostly-random UUIDs while providing the database performance and sortability of sequential IDs. Its main purpose is to serve as an efficient, modern primary key in databases and a sortable identifier in distributed systems.

Regardless of which one of us is right or wrong in our assertions of CreateVersion7 RFC 9562 compliance, the fact is that .NET 9+ failed to provide a usable UUIDv7 API that allows .NET developers to use SqlClient (.NET official SQL APIs) to create sortable 16-byte unique identifiers.

At best, @tannergooding is right: CreateVersion7 is RFC-compliant, but is useless to .NET developers. No one can provide working .NET SqlClient code (neither for SQL Server - Microsoft flagship database - nor for PostgreSQL or Oracle).

At worst, I'm right: CreateVersion7 is just as useless, but at least I'm providing the .NET community with working code.

@tannergooding
Copy link

but at least I'm providing the .NET community with working code.

@sdrapkin It isn't working, it's broken in a different direction. i.e. you've effectively done the NewSequentialGuid() proposal.

Given your FastGuid.New*Guid() APIs, they all produce invalid Guid.Variant and Guid.Version properties (per the RFC). Additionally, because they are swapped on the .NET side compared to what System.Guid expects, the following test can fail:

var x = SecurityDriven.FastGuid.NewSqlServerGuid();
Thread.Sleep(10);
var y = SecurityDriven.FastGuid.NewSqlServerGuid();

if (x > y)
{
    throw new Exception("NOT ORDERED!!!");
}

So all you've done is taken the problem and moved it somewhere else. The database is now ordered because you're fixing its bug, but you've left a clear and obvious pit of failure for any other consumer already doing the "right" thing or who are attempting to work with the same values on the .NET side of things.

Your NewGuid API returns a non-compliant UUID and is only "faster" because its moving the cost of the RNG up ahead of the normal measurement, so that its amortized. i.e. It's effectively pooling and pulling from the pool. It's something anyone could do to save on time and make the benchmark look better.

Your NewPostgreSqlGuid has the same fundamental issue as CreateVersion7, which is that the responsibility is on the consumer to use the correct serialization API, but additionally returns a non-compliant UUID value (per the RFC).

@sdrapkin
Copy link
Author

@sdrapkin It isn't working, it's broken in a different direction

@tannergooding It seems that your definition of "broken" is "does not comply with RFC 9562". FastGuid never claimed RFC 9562 compliance - in fact it explicitly and intentionally ignores RFC 9562 entirely. There is no Variant, Version, or other silly artifacts of that RFC. Users of FastGuid use it not only because it's very fast (which is nice), but also because the NewPostgreSqlGuid() & NewSqlServerGuid() methods reduce db fragmentation, and provide 64 bits of randomness with 64 bits of DateTime timestamp, which - for those who're counting - is 10,000 times more precise that UUIDv7 spec. That extra precision also helps reduce timestamp collisions due to how fast FastGuid generators are (ie. lower latency naturally causes more timestamp collisions in a hot loop).

You do raise an interesting question of .NET-side sequential sortability for Guids returned by NewPostgreSqlGuid(). I've never had a user-request for it, since 99% of users use the NewPostgreSqlGuid() method for what its name implies: generating database-sortable Guids. However, adding client-side sequential sortability for those who need it is trivial:

public sealed class SequentialSqlServerGuidComparer : IComparer<Guid>
{
	public int Compare(Guid x, Guid y)
	{
		var x_timestamp = SecurityDriven.FastGuid.SqlServer.GetTimestamp(x);
		var y_timestamp = SecurityDriven.FastGuid.SqlServer.GetTimestamp(y);

		int ts_compare = x_timestamp.CompareTo(y_timestamp);
		if (ts_compare == 0) return x.CompareTo(y);
		return ts_compare;
	}
}

void Main()
{
	var guids = new List<Guid>();
	for (int i = 0; i < 20; ++i)
	{
		guids.Add(SecurityDriven.FastGuid.NewSqlServerGuid());
		Thread.Sleep(10);
	}

	guids.Sort(new SequentialSqlServerGuidComparer());
	guids.Dump();
}

FastGuid.NewGuid() method returns an instance of fully 128-bit random System.Guid structure, and thus cannot be "non-compliant" with anything because it is not trying to comply with anything other than returning a 128-bit random System.Guid (which it fully succeeds at). Those 1% who need to set Version and Variant can always do it with trivial bit-flipping.

@justinbrick
Copy link

Disclaimer: Non-contributing

Reading this chain, this seems like a failing of the methods Guid exposes, as well as poor design choices by Microsoft. Of which, without their infinite wisdom, never would have lead to this conversation in the first place.

  1. Why is the default behavior of any conversion method to bytes NOT big endian by default? This is poor design choice #1, as this would've been assumed to be the default behavior by any sane developer with two or more brain cells to rub together.
  2. The RFC states the Microsoft behavior as an exception. I personally take this to mean that, inherently, Microsoft has made such a mess of things that it required writing its own disclaimer. Not that this is how it SHOULD be.
  3. I've not looked at the timing of this, nor do I care to, but regardless of any chronology of which the standard was written or the borked implementation, it's apparent that Microsoft's Guids are the odd one out. It's not atypical for Microsoft to deviate from the norm, surely for whatever sense of euphoria their engineers get when they frustrate the developers that use their platform, but it is all the more disappointing. Maybe this is a sign for them to just throw the whole thing away? I have a brilliant idea, maybe they should name the new class to replace it "UUID". Thoughts?

@akiraveliara
Copy link

akiraveliara commented Nov 13, 2025

@justinbrick

Why is the default behavior of any conversion method to bytes NOT big endian by default?

nothing else in .NET is big endian by default, and i recall reading in one of the issues that it would likely just not be optional to specify endianness. introducing one big endian default where everywhere else defaults to little endian or makes you be explicit would be a poor design choice in its own right.

Maybe this is a sign for them to just throw the whole thing away? I have a brilliant idea, maybe they should name the new class to replace it "UUID". Thoughts?

or they could just. add a flag to serialize as big endian. and then everyone whose code is broken because they ignored/didn't know about endianness just needs to add a boolean to a single line of code instead of migrating to a new type across an entire codebase. thoughts?

for the record that's what they ended up doing; guid.TryWriteBytes(bytes, bigEndian: true, out _); just works

@justinbrick
Copy link

@akiraveliara

nothing else in .NET is big endian by default, and i recall reading in one of the issues that it would likely just not be optional to specify endianness. introducing one big endian default where everywhere else defaults to little endian or makes you be explicit would be a poor design choice in its own right.

this is a loaded message, because the way you worded this sentence makes me assume you've misinterpreted what i've said. in the case you think i'm proposing the internal representation be big endian, you couldn't be more wrong - i couldn't care less about the internal representation. the entire point of abstracting it is so that no one has to care. what people do care about, however, is the output, as that's where the standards need to be met. if this is the case, you can stop here and save yourself from some passive aggressive comments. if you did interpret my message correctly, however, and still came to that conclusion, i urge you to read below.

"everything else is little endian" is a poor excuse for failing to return a byte representation in its expected format. you would be mistaken to think all other structures or types return their byte ordering as little endian for giggles. because if I had to chance a guess, they're probably doing it in little endian because they'd be expected to, not just because they can. UUIDs are just one of those areas where its expected to be big endian.

or they could just. add a flag to serialize as big endian. and then everyone whose code is broken because they ignored/didn't know about endianness just needs to add a boolean to a single line of code instead of migrating to a new type across an entire codebase. thoughts?

you're right - this is such a fundamental flaw that it's almost awful to consider the implications of migrating everything all at once from such awful code. if we're going to assume that incremental migrations are impossible to do, and that tactics such as implicit casts don't exist, you're right, this is a monumental effort. unfortunately, since no such thing exists, i guess the only thing we can do is urge people to never use C#, for such an awful language it is to not support any such features.

regarding the example you gave, i'm sure it works. i can make a cake out of turds; doesn't mean anyone would want to eat it.

@tannergooding
Copy link

@justinbrick you're using some fairly volatile wording, but that wording could be equally applied to people doing the simple thing of "reading the RFC which clearly covers the historical aspects and problems for this across a broad range of implementations and that they are not specific to Microsoft".

That is, the latest RFC 9562 explicitly details that the UUIDs have evolved a lot since their original introduction and that there have been many errata having to clarify common bugs, byte ordering issues, decoupling of concepts, fixing security issues, and more. Because this isn't a Microsoft specific issue, it is a general problem with UUIDs and them being a 35+ year old construct that originated long before the RFC existed and where multiple platforms have historically handled things differently for many reasons.

The history is important and there are many resources on it. But the basics is that it started in the 1980s for the Network Computer System, later evolved into what was formally called UUIDs for the Distributed Computing Environment, and was then adopted by Windows. All of this happened over a decade before the first RFC 4122 was published in 2005.

You can still see the original field layout in the latest RFC as part of UUID v1, but what it boils down to is that it was formally defined as a C struct that looked like this (using somewhat more modern C syntax to avoid ambiguity):

struct UUID
{
    uint32_t time_low;
    uint16_t time_mid;
    uint16_t time_hi_and_version;
    uint8_t clock_seq_hi_and_reserved;
    uint8_t clock_seq_low;
    uint8_t node[6];
};

The original DCE/R{C spec also explicitly calls out (because it was given a C definition)

Depending on the network data representation, the multi-octet unsigned integer fields are subject to byte swapping when communicated between different endian machines.

This was important because it was still the "earlier" days of networking. There was a lot of variance and while RFC 1700 in 1994 started requiring that TPC/IP use big-endian as the standard, there was a monumental amount of code, RPC layers, and other things that still worked in "host order" and so was often little-endian as that was the most prominent for CPUs at the time. -- A reader who is paying attention may also note that the Win32 definition of GUID remains compatible with this original C definition (largely just collapsing the 8 trailing uint8_t fields into a single Data4[8]).

Now shift forward more years, people have introduced several new versions (v2-v5) and while there are various docs here and there, there isn't a single centralized body aggregating it all. So some people get together and publish RFC 4122 (https://www.rfc-editor.org/rfc/rfc4122). This initial spec is (to my knowledge) the first time that UUID specification puts a heavier restriction on the binary representation of the fields, describing them as "16x octets" and specifying that outside of a spec saying otherwise that they should be "big endian" (network byte order). But this was explicitly done and called out because of the literal decade+ worth of code that was doing it over other protocols or scenarios and where host-endianness was required and the attempt to normalize it moving forward was in recognition of some of the primary use-cases and importance of having a well-defined standard for cross computer communication, which particularly tends to happen over the network where big-endian is standard.

By this point, it is impossible to "fix" Win32, COM, .NET, or any of the dozens of other protocols that existed (many of which are still used to this day, including on Linux and Unix in general) to require big-endian. It would be a massive and detrimental binary break that would set everything back significantly. You also can't expose a new type because that causes additional confusion and problems, it also wouldn't be compatible with all the scenarios using UUIDs that are documented (and allowed to be, per the RFC) to be using host-endianness (especially for back-compat).

We then move forward another 19 years and we get today's spec, RFC 9562 (https://www.rfc-editor.org/rfc/rfc9562), which attempts to further clarify the 35+ years of historical issues that exist across the industry by simplifying parts of the spec and declarations there-in; but which still calls out the nuance and importance that many things still deviate and many implementations get this stuff wrong.


This isn't some simple mistake, it isn't some Microsoft specific problem, it isn't something that can just be fixed with new types or people reading docs. It is a widespread and fairly pervasive issue that has existed for 35+ years and which will likely never go away. It is likewise no different from every other C-like struct in existence which has to be serialized as big-endian to be sent as part of a TCP/IP or general network packet. This is just part of programming due to most machines being little-endian and most networking being big-endian.

You have to ensure your data is correctly serialized and deserialized.

@justinbrick
Copy link

@tannergooding Beautifully written - seeing this context in place before everything else makes it much more clear on the reasons that define why it has come to be at this point.

One question that still lives from this, is why has this dated behavior not been fixed under breaking changes? Surely with the addition of .NET versions, it would be reasonable to assume that since these new .NET versions are considered major under semantic versioning, a change to newer .NET may require consideration for changes to existing usages? This has been done already for other areas in .NET.

The code shape as it stands seems perfectly reasonable to maintain exactly as it is, save for getting byte representations - fixing a minor issue such as default byte ordering might cause headaches for those using dated systems, but adhering to standards will make a much more cohesive system in the long run.

If the justification is to match COM, or any other system that might have the same arbitrary structure, I think that is ultimately an issue that should be posed to those still maintaining these legacy systems (most likely Microsoft w/ Windows, I imagine?). It seems to me a fool's errand to bend backwards for supporting systems built in legacy, as if they're legacy to this point, they obviously need to be swapped out, and are only liabilities.

Every other language has since fixed this behavior, if it was an issue for them before - I was not able to find examples like you mention where you've stated it is an ongoing problem, except for legacy systems like COM. It seems unreasonable to justify giving C# a pass, when the tech stacks everywhere else do not demonstrate this issue.

@tannergooding
Copy link

One question that still lives from this, is why has this dated behavior not been fixed under breaking changes

Because it wouldn't fix it, it would just cause more breaks including introducing security vulnerabilities and other problems, particularly as it applies to existing databases.

What would actually happen is that the millions of lines of existing COM, RPC, and other code that rely on the existing little-endian behavior would now silently break when they roll forward onto modern .NET. In practice this risks arbitrary code execution and potentially even security vulnerabilities due to buffer overruns, stack return overwriting, and all number of other issues. You would equally find that existing databases that have been using Guid for their ID's for 25+ years now can no longer resolve their keys or worse they resolve to the wrong key. Codebases that are manually working around any known bugs in a database provider would also be broken.

The only viable fix, both due to the sheer amount of existing code but also due to the potential confusion that introducing a new type would cause, is to have overloads of the serialization/deserialization APIs that allow you to specify the endianness. This is also then inline with how you need to consider serialization for every other type, including types like int or long which are much more common to serialize as part of databases, over the network, etc.

Every other language has since fixed this behavior, if it was an issue for them before

Every language has this same general issue where the user needs to be considerate of the serialization format. Newer languages that provide a built-in UUID type tend to default to big-endian, but you still have to be considerate when interacting with things like RPC or various file systems. Older languages tend to not provide anything built-in and so what you get is dependent on the library/framework you're using. Where something is provided you still have to consider which its documented to use and what the target format is.

I was not able to find examples like you mention where you've stated it is an ongoing problem

One common example that every computer has to deal with is ACPI (Advanced Configuration and Power Interface). This is the fundamental power management interface for all computers that allows it to do things like sleep, hibernate, shutdown, restart, etc. There are also various RPC (Remote Procedure Call), file system, and other specs (particularly those with origins from the early/mid 90's) that all document themselves as following the ISO/IEC 11578:1996 - OSI - RPC or DCE 1.1 spec which maintains "host endianness". It's only newer specs, typically those started post 2005, which tend to instead use RFC 4122. One example of such a newer spec is UEFI which is maintained alongside the ACPI spec and so is a core scenario that any modern OS needs to handle both big and little-endian UUIDs as part of their basic boot process and general hardware management.

These would be areas where other languages have the inverse problem to C#/.NET. That is, the default serialization is big-endian, but they must explicitly ensure they load/store as little-endian on most machines to correctly interface with these scenarios.

Serialization is always something code needs to be considerate of and binary serialization in particular is tricky. It is something where apps need to be explicit and where they can easily write bugs if they aren't. What works on an x64 machine (little-endian) may not work on an IBM Z/Architecture machine (big-endian). It may not work between the Xbox 360 (PowerPC - Big Endian) vs the Xbox One (x64 - Little Endian) or on Arm devices where the default is little-endian, but where they can support and sometimes even dynamically toggle the mode they target.

It is why we (and other ecosystems) typically provide both big and little-endian APIs where binary serialization is a consideration. So developers can do the right thing based on host architecture and depending on what domain they are working with (networking, some spec for a file extension like elf/pe/png/jpg/zip/etc, file system, hardware, ...). Because it is a fundamental and very basic consideration that people can't get away from, no matter how the ecosystem is setup.

@MilanNemeth-Eviden
Copy link

MilanNemeth-Eviden commented Dec 7, 2025

My 2 cents: MongoDB's C# driver perfectly handles byte order differences...
So in theory, network transmission and DB ops can benefit from big-endianness, while the app can leverage little-endianness at runtime.

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