Random Numbers, GUIDs and UUID v7
Objective
"Give me a random number" and "give me a unique id" sound like one problem and are
at least three. A game wants fast, repeatable pseudo-randomness. A password reset
token wants unpredictability an attacker cannot model. A database key wants
uniqueness, and, if it is a clustered key, an order that does not scatter inserts
across the index. .NET has a tool for each: Random (including the thread-safe
Random.Shared), RandomNumberGenerator for security, and Guid, which since
.NET 9 can create version 7 (time-ordered) values. This concept shows what each
one guarantees, the newer helpers (Shuffle, GetItems), and where version 7
GUIDs help and where their ordering is weaker than people assume.
Use Cases
- Simulations, games, sampling and tests that need many cheap random values, often repeatable from a seed.
- Generating tokens, password-reset codes, API keys and salts that must not be guessable.
- Shuffling a deck or picking random items from a list without writing Fisher-Yates by hand.
- Choosing a primary-key type:
intidentity, random GUID, or time-ordered GUID.
Deep Dive
Random: fast, seedable, not secret
Random is a pseudo-random generator. The same seed gives the same sequence,
which the docs call out as useful for test suites and replaying games, and which
is exactly why it is useless for secrets. The docs also warn that the sequence
for a given seed may differ between .NET versions, so do not persist seeds as
a contract:
csharpvar a = new Random(42);
var b = new Random(42);
Console.WriteLine($"{a.Next()} {b.Next()}"); // 1434747710 1434747710
Console.WriteLine(a.Next(1, 7)); // 1..6: upper bound is exclusive
Console.WriteLine(a.NextInt64(10_000_000_000)); // a long below the boundAn instance is not thread safe per the docs, so sharing one new Random()
across threads is a bug. Since .NET 6, Random.Shared is a ready-made thread-safe
instance. We called it from Parallel.For 1000 times without trouble:
csharpvar set = new ConcurrentBag<int>();
Parallel.For(0, 1000, _ => set.Add(Random.Shared.Next(100)));
Console.WriteLine(set.Count); // 1000, no exceptionsYou cannot seed Random.Shared. When you need reproducibility (a test, a
replay), create your own new Random(seed) and keep it on one thread.
Shuffle, GetItems and the .NET 10 string helpers
.NET 8 added two helpers that replace common hand-written loops. Shuffle mixes
an array or span in place. GetItems picks values with replacement from a set of
choices, so the same choice can come out twice:
csharpint[] arr = [1, 2, 3, 4, 5, 6, 7, 8];
new Random(1).Shuffle(arr);
Console.WriteLine(string.Join(",", arr)); // 2,1,5,7,4,3,6,8
string[] colors = ["red", "green", "blue"];
Console.WriteLine(string.Join(",", new Random(1).GetItems(colors, 5)));
// red,red,green,blue,green (five picks from three choices: repeats are expected)The outputs above are for seed 1 on this SDK, and per the docs may differ in
another version. GetItems has overloads that take a destination span. Per the API
docs, .NET 10 adds Random.GetString(choices, length) and Random.GetHexString(length):
csharpConsole.WriteLine(new Random(1).GetString("abc", 8)); // aabcbbbc
Console.WriteLine(new Random(1).GetHexString(8)); // 317CA65FThey are convenient for test data. Remember that they inherit Random's
predictability.
RandomNumberGenerator for anything an attacker would guess
The docs' guidance is direct: for "a cryptographically secure random number, such
as one that's suitable for creating a random password", use the static methods of
RandomNumberGenerator. Since .NET 8 it mirrors the helpers above:
csharpbyte[] token = RandomNumberGenerator.GetBytes(16); // 16 random bytes
int die = RandomNumberGenerator.GetInt32(1, 7); // 1..6, upper bound exclusive
string code = RandomNumberGenerator.GetHexString(8); // 8 hex chars
string pin = RandomNumberGenerator.GetString("0123456789", 6); // 6 digits
RandomNumberGenerator.Shuffle<int>(numbers); // secure shuffle in placePrefer GetInt32 over reducing random bytes with % n yourself. Secure
generation costs more than Random, which rarely matters for tokens. For URL-safe output, pair
the bytes with Base64Url from the encodings concept. This section stays at the
level of "which API"; designing a token scheme (expiry, storage, comparison) is a
security topic of its own.
Guid: v4 and v7
Guid.NewGuid() makes a version 4 value: random bits plus the version and variant
markers. In .NET 9, Guid.CreateVersion7() makes a version 7 value following
RFC 9562, which puts a Unix-millisecond timestamp in the first 48 bits and fills
the rest with random data. The docs state that the method uses UtcNow as the
timestamp and "seeds the rand_a and rand_b sub-fields with random data". There is
also an overload that takes a DateTimeOffset (it throws
ArgumentOutOfRangeException before the Unix epoch), and Guid.Version and
Guid.Variant properties to inspect a value (also .NET 9):
csharpvar g4 = Guid.NewGuid();
Console.WriteLine($"{g4.Version} {g4.Variant}"); // 4 9
var t = DateTimeOffset.UtcNow;
var g7 = Guid.CreateVersion7(t);
Console.WriteLine(g7); // 01a140fe-3a48-7c7e-98b3-8f9b5668440e
Console.WriteLine(g7.ToString("N")[..12] == t.ToUnixTimeMilliseconds().ToString("x12")); // TrueThe first 12 hex digits are the millisecond timestamp, and the 7 after the second
dash is the version.
What "sortable" really means
The timestamp prefix is the point: values created later sort later, so inserts land near the end of an index instead of at random places, which is the usual argument for fewer page splits and better cache behavior on a clustered key (the benefit depends on your database engine, so measure it there). But the guarantee is only at millisecond granularity, because the remaining bits are random. We measured it:
csharpvar spaced = new List<Guid>();
for (int i = 0; i < 5; i++) { spaced.Add(Guid.CreateVersion7()); Thread.Sleep(3); }
Console.WriteLine(spaced.SequenceEqual(spaced.OrderBy(x => x))); // True: 3 ms apart
var burst = Enumerable.Range(0, 10000).Select(_ => Guid.CreateVersion7()).ToList();
Console.WriteLine(burst.SequenceEqual(burst.OrderBy(x => x))); // False: tight loop
int inversions = 0;
for (int i = 1; i < burst.Count; i++)
if (string.CompareOrdinal(burst[i - 1].ToString(), burst[i].ToString()) > 0) inversions++;
Console.WriteLine(inversions); // 4966 of 9999 adjacent pairs out of order on this machineSo two ids generated in the same millisecond have no defined relative order. If you need a strict order (an event log, say), add a sequence column or use a generator with a counter. Do not rely on creation order inside a burst.
Ordering also depends on the database. SQL Server's docs say ordering of
uniqueidentifier "is not implemented by comparing the bit patterns", so do not
assume that the byte or string order you see in .NET matches the order the
database uses for a GUID column. For SQL Server keys, EF Core's docs say the provider already
generates sequential GUIDs client-side when a GUID is a primary key, so you
normally leave that to the framework rather than call CreateVersion7 yourself.
Check your provider's documentation before choosing.
Finally, a v7 GUID leaks its creation time to anyone who reads the first 12 hex digits. Do not use it where a public id should not reveal when a row was created.
Trade-offs
Randomis cheap and reproducible, and predictable. Fine for games and tests, unacceptable for tokens. If the value gates access to anything, useRandomNumberGenerator, even if the code looks identical.Random.Sharedtrades control for convenience. It is thread safe and needs no setup, but you cannot seed it, so tests that need repeatability should inject a seededRandominstead.- Version 7 GUIDs improve insert locality, not strict ordering. The win is real for clustered indexes, and it is limited to millisecond resolution. Within a millisecond they are random, and across databases the sort order differs.
- v7 reveals time, v4 does not. A random v4 GUID is the safer public identifier. Use v7 for internal keys, and consider a separate public id.
- GUIDs are bigger than ints. 16 bytes in every index and foreign key, and unreadable in logs. Time ordering fixes fragmentation, not size.