If you have been working with .NET and databases for more than a few years, you know the debate. It’s the one that comes up in every architectural review, every schema design meeting, and every “why is the database slow” war room.
Integer IDs vs. GUIDs.
On one side, you have the performance purists. They want int or bigint Identity columns. They are right, technically. Integers are small (4 or 8 bytes), they sort perfectly, and databases like SQL Server love them. But they are a pain in distributed systems. You can’t generate an ID on the client; you have to ask the database for permission every time you create a record.
On the other side, you have the flexibility camp. They want GUIDs (UUIDs). You can generate them anywhere—offline, in a browser, on a microservice—without talking to a central authority. But DBAs hate them. And they are right to be mad.
The standard Guid.NewGuid() generates a Version 4 UUID. It is chaos. It creates completely random data. When you force a database to cluster its primary index on a random value, you are essentially asking the engine to fight against itself.
For a long time, we were stuck choosing between “fast but rigid” (Integers) or “flexible but slow” (GUIDs).
But with .NET 9, the game has changed. We finally have a native, standardized solution that gives us the best of both worlds. Enter UUID Version 7.
The Hidden Cost of Guid.NewGuid()
To understand why Version 7 is such a big deal, we have to look at what actually happens when you save a standard GUID to your database.
Most relational databases (SQL Server, PostgreSQL, MySQL) use a structure called a B-Tree for their indexes. Specifically, the Clustered Index (which is usually your Primary Key) dictates the physical order of the data on the disk. B-Trees rely on order to be efficient. They want data to arrive sequentially: 1, 2, 3, 4…
When data arrives in order, the database fills up a data page (think of it like a bucket), closes it, and moves to the next empty bucket. It is fast, clean, and keeps the hard drive happy.
The “Page Split” Disaster
Now, imagine you use Guid.NewGuid(). You get a value that starts with F2.... The database puts it near the end of the table. The next value you generate starts with 0A.... The database has to put this at the beginning.
Suddenly, you are inserting data into the middle of full buckets. The database looks at a data page, sees it is full, and realizes it needs to squeeze this new random GUID right in the middle. It has to perform a Page Split.
It takes the full page, rips it in half, moves half the data to a new page, updates all the pointers, and then inserts your row. This happens constantly.
The result?
- Index Fragmentation: Your data is scattered physically, even if it is logically connected.
- ** wasted Space:** Your pages end up being 50-60% full instead of 90-100% full. You are paying for storage you aren’t using.
- IO Throttling: Your disk I/O goes through the roof because the database is constantly shuffling pages around just to write a single row.
For years, we used hacks to fix this. We used “Comb GUIDs” (combining time and random bytes manually) or SQL Server’s NEWSEQUENTIALID(). But these were non-standard workarounds.
How UUID v7 Fixes Everything
UUID Version 7 is part of the new RFC 9562 standard. It wasn’t invented by Microsoft, but Microsoft has embraced it fully in .NET 9.
The magic of v7 is that it is time-ordered.
A standard UUID is 128 bits. In Version 7, the bits are structured specifically to ensure that if you generate two IDs, the one generated later will (almost always) have a higher value than the one generated earlier.
Here is the breakdown of the 128 bits:
- Unix Timestamp (48 bits): The number of milliseconds since the Unix Epoch. This takes up the front of the GUID.
- Version & Variant (6 bits): Metadata that says “I am a v7 UUID.”
- Random Entropy (74 bits): Random noise to ensure uniqueness, even if two IDs are generated at the exact same millisecond.
Because the timestamp is at the beginning (the “most significant bits”), sorting these GUIDs sorts them by time.
When you send a stream of UUID v7s to your database, they arrive in (mostly) sequential order. The database sees an incoming ID, looks at the B-Tree, and says, “Oh, this is higher than the last one I wrote. I’ll just tack it onto the end.”
No page splits. No fragmentation. Just smooth, sequential writes.
Implementing it in .NET 9
Using this in C# used to require third-party libraries like NewId (shout out to MassTransit’s Chris Patterson, who has been solving this for years). Now, it is native.
using System;
// OLD WAY: The chaotic evil option
// Generates: 9d3e4a1b-7c8d-4e5f-9a0b-1c2d3e4f5a6b
Guid badGuid = Guid.NewGuid();
// NEW WAY: The lawful good option
// Generates: 018e6b20-5f1a-7b3d-8000-a1b2c3d4e5f6
Guid goodGuid = Guid.CreateVersion7();
If you run Guid.CreateVersion7() inside a loop, you will notice the first block of characters increments steadily.
Extracting the Time
One of the coolest side effects of v7 is that the ID itself contains the creation time. You technically don’t need a CreatedUtc column in your database anymore if you only need millisecond precision (though I’d still keep one for readable queries).
You can actually pull the time back out:
var id = Guid.CreateVersion7();
// This isn't built-in as a one-liner yet, but the logic is simple:
// Extract the first 48 bits to get the Unix timestamp.
This is incredibly useful for debugging distributed systems. If you see a log ID, you immediately know roughly when it happened without looking up the record in the database.
The SQL Server “Endianness” Nuance
I need to be real with you—there is a catch. If you are using PostgreSQL, you can stop reading; v7 works perfectly. If you are using SQL Server, it’s a little more complicated.
SQL Server is… unique. When it stores a uniqueidentifier, it doesn’t just store the bytes sequentially. It stores them using a mix of little-endian and big-endian sorting for the first 8 bytes.
Basically, if you give SQL Server a GUID like 00112233-4455-6677-8899-aabbccddeeff:
- It sorts the first part (
00112233) in reverse byte order. - It sorts the second part (
4455) in reverse. - It sorts the third part (
6677) in reverse. - It sorts the last two parts (
8899and the rest) normally.
This means that Guid.CreateVersion7() will not be perfectly sequential inside SQL Server’s specific sorting logic. It will be mostly sequential, but the bytes get jumbled slightly because of how Microsoft defined the GUID type decades ago.
Should you worry about this?
For 95% of applications? No.
Even with the SQL Server shuffling, a v7 UUID is infinitely better than a v4 UUID. The timestamp changes slowly enough that you still get “locality.” You might get page splits within the current leaf node, but you won’t be jumping all over the disk file. The fragmentation reduction is still massive.
If you are a performance fanatic and need perfect sequential inserts in SQL Server specifically, you might need a custom implementation that “swaps” the bytes before creating the Guid object, so that when SQL Server un-swaps them, they end up in the right order. But for the vast majority of us, standard v7 is the sweet spot between perfection and ease of use.
Migration: How to Start Using It
You don’t need to rip out your database schema. uniqueidentifier columns don’t care what version the UUID is. They just store 128 bits.
1. For New Projects
This is a no-brainer. Configure your Entity Framework Core context or your repository to use v7 by default.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.Property(o => o.Id)
.HasValueGenerator<VirtualVersion7ValueGenerator>();
// Note: You might need a custom generator or just set the ID
// in the constructor of your entity:
// public Order() { Id = Guid.CreateVersion7(); }
}
Setting the ID in the constructor is my preferred approach. It ensures the ID exists before the object ever touches the persistence layer, which is great for domain-driven design.
2. For Existing Systems
You can start mixing v7 into a table that already has v4 data. It won’t break anything. The new rows will just start grouping together at the “end” of the index logic. Over time, as old records become historical, your active working set (the new data) will be nice and sequential in the buffer pool.
Security Considerations
We need to address the one downside. Privacy.
Random v4 UUIDs are great because they are unpredictable. You cannot guess the next ID. You cannot look at an ID and know when it was created.
UUID v7 leaks time. If I register for your site and my ID is ...001, and then I create another account 5 minutes later and it’s ...500, I can calculate roughly how many users signed up in those 5 minutes.
If your Primary Keys are exposed in URLs (e.g., yoursite.com/users/{guid}), and user count is a trade secret, or you are worried about enumeration attacks, do not use v7 as your public ID.
In that scenario, use v7 for the internal Primary Key (for database speed) and add a separate, random v4 GUID (or a NanoID) as a “PublicId” column for the URL. Best of both worlds.
The Verdict
We have spent years apologizing for using GUIDs. We have added NEWSEQUENTIALID() constraints. We have argued with DBAs. We have accepted 99% fragmentation levels as “just how it is.”
That era is over.
The arrival of Guid.CreateVersion7() in .NET 9 is a signal. It says that Microsoft acknowledges that the industry has moved toward distributed IDs, and the language is finally catching up to support that efficiently.
It is a small change in code—literally one method call—but it represents a massive shift in backend engineering. You get the scalability of distributed systems with the storage efficiency of monolithic integers.
So, go ahead. Open up that User class. Find the line where you assign Guid.NewGuid(). Delete it. Type Guid.CreateVersion7().
Your database will appreciate it.