jaytwo.StableHashing
0.1.0-beta-20260927074007
dotnet add package jaytwo.StableHashing --version 0.1.0-beta-20260927074007
NuGet\Install-Package jaytwo.StableHashing -Version 0.1.0-beta-20260927074007
<PackageReference Include="jaytwo.StableHashing" Version="0.1.0-beta-20260927074007" />
<PackageVersion Include="jaytwo.StableHashing" Version="0.1.0-beta-20260927074007" />
<PackageReference Include="jaytwo.StableHashing" />
paket add jaytwo.StableHashing --version 0.1.0-beta-20260927074007
#r "nuget: jaytwo.StableHashing, 0.1.0-beta-20260927074007"
#:package jaytwo.StableHashing@0.1.0-beta-20260927074007
#addin nuget:?package=jaytwo.StableHashing&version=0.1.0-beta-20260927074007&prerelease
#tool nuget:?package=jaytwo.StableHashing&version=0.1.0-beta-20260927074007&prerelease
jaytwo.StableHashing
Deterministic non-cryptographic one-shot hashing for keys and ids. Same input produces the same hash across runs, processes, machines, and .NET versions. Not a System.IO.Hashing replacement, and not for cryptography.
Charter
The GetHashCode you can persist. This library wraps well-known non-cryptographic hash functions (NCHF) with fixed algorithms and seeds so the same input always produces the same output regardless of runtime, platform, or process.
Use it for shard keys, cache keys, bucket ids, and stable identifiers from strings or bytes.
Do not use it for passwords, signatures, MACs, file integrity, or anything an adversary can game.
In scope
- Hash
string,byte[], andReadOnlySpan<byte>touint/ulong/UInt128/ hex / signed integers - Well-known NCHFs, with
StableHash.Defaultpinned to XXH3 seed 0 for the life of 1.x - Output shapes that keys need (
forcePositive, hex)
Out of scope
- Streams, files, and incremental hashing. Use
System.IO.Hashing(XxHash3,XxHash32,XxHash64,XxHash128). - Cryptographic hashes, MACs, and password hashing (SHA, HMAC, bcrypt, Argon2, ...)
- Consistent hashing, hash rings, and jump hash
- CRC and other checksums. They detect accidental corruption; they are a poor stable
GetHashCode.System.IO.Hashingalready shipsCrc32andCrc64.
Why it exists
.NET's built-in GetHashCode() is randomized per-process by default (since .NET Core), so it cannot be stored, shared across processes, or used as a shard key.
Non-cryptographic hashes are fast and well-distributed. They make no security guarantees: a determined attacker can construct collisions. If you need those guarantees, use a cryptographic primitive instead.
Features
StableHashis a registry of well-known hashers grouped by family.StableHash.Defaultis XXH3 seed 0, 64-bit (xxh3-64), pinned for the life of 1.x.- The headline use case is a
GetHashCodeyou can persist, andGetHashCodeis anint:StableHash.Default.Narrow32().HashDataAsInt32(value).Narrow32()takes the low 32 bits of the digest and hands back anIHash32namedxxh3-64-32, so the narrowed value stays self-describing. - Every hasher is an
IHash32,IHash64orIHash128. Nothing bundles two widths into one object, so no single "hasher" can carry two disagreeing seeds. - Seeds follow the algorithm, not the output width:
StableHash.XxHash.CreateHasher32takes auintbecause XXH32 is natively 32-bit seeded, while every other member takes aulong. - Families: XXH3 (64/128-bit), classic xxHash (XXH32 / XXH64), RapidHash V3 (64-bit). 32 bits of a 64-bit family is
Narrow32(), not a separate hasher. - One-shot hashing:
ReadOnlySpan<byte>is the core;byte[]andstringguard/encode then hash. XXH3 and xxHash callSystem.IO.Hashing; RapidHash is implemented here. - Hexadecimal, signed, and
forcePositiveoutput on every interface through extensions, 128-bit included - Every hasher reports a canonical
Nameyou can persist next to the value - UTF-8 encoding by default for strings; custom
Encodingoverloads available - Targets net462, netstandard2.0, net6.0, and net8.0
Installation
Add the NuGet package:
PM> Install-Package jaytwo.StableHashing
Usage
StableHash.Default is the entry point. Everything else is reached by naming a family and a width: StableHash.XxHash3.Hasher64, StableHash.RapidHash.Hasher64. Each returns an IHash32 / IHash64 / IHash128.
Every family exposes the same two shapes. The HasherNN properties are the unseeded (seed 0) instances - cached singletons, safe to share. The CreateHasherNN(seed) methods take a seed, handing back that same cached instance for seed 0 and a new one otherwise. Reach for the property unless you are seeding.
Hash a string to an integer
using jaytwo.StableHashing;
// 64-bit unsigned (the full XXH3 digest) - prefer this whenever you can store 64 bits
ulong hash64 = StableHash.Default.HashData("hello"); // 0x9555E8555C62DCFD
// Narrowed to 32 bits: the LOW 32 bits of that same digest, nothing else
IHash32 narrowed = StableHash.Default.Narrow32(); // cached; the same instance every call
uint hash32 = narrowed.HashData("hello"); // 0x5C62DCFD
// Signed (may be negative). This is the persistable GetHashCode.
int signed32 = narrowed.HashDataAsInt32("hello");
long signed64 = StableHash.Default.HashDataAsInt64("hello");
// Signed, guaranteed non-negative (high bit cleared)
int positive32 = narrowed.HashDataAsInt32("hello", forcePositive: true);
long positive64 = StableHash.Default.HashDataAsInt64("hello", forcePositive: true);
Hash to a hex string
string hex64 = StableHash.Default.HashDataAsHex("hello"); // "9555e8555c62dcfd"
string hex32 = StableHash.Default.Narrow32().HashDataAsHex("hello"); // "5c62dcfd" (the low half)
string hex128 = StableHash.XxHash3.Hasher128.HashDataAsHex("hello"); // net7.0+
Choose a different algorithm
using jaytwo.StableHashing;
string hex = StableHash.XxHash.Hasher64.HashDataAsHex("hello"); // "26c7827d889f6da3"
ulong rapid = StableHash.RapidHash.Hasher64.HashData("hello"); // 0x2E2D7651B45F7946
Seeding
Call the family's CreateHasherNN(seed), or construct an adapter directly. Each takes the seed width its algorithm actually uses, so there is nothing to get wrong.
IHash64 seeded = StableHash.XxHash3.CreateHasher64(42); // ulong seed
IHash32 seededClassic = StableHash.XxHash.CreateHasher32(42u); // uint seed - XXH32 is 32-bit seeded
IHash32 both = seeded.Narrow32(); // seeding and narrowing compose
both.Name; // "xxh3-64-32;seed=42"
var adapter = new XxHash3Hasher(seed: 42); // equivalent to the first line
Hash bytes
byte[] data = SomeMethod();
ulong hash = StableHash.Default.HashData(data);
string hex = StableHash.Default.HashDataAsHex(data.AsSpan());
Custom encoding
using System.Text;
ulong hash = StableHash.Default.HashData("hello", Encoding.Unicode);
Streaming and incremental hashing
Not provided here on purpose. Use System.IO.Hashing directly. A plain using of both namespaces does not clash, so the example below needs no alias. using static StableHash is the exception: it imports the nested family names XxHash3 / XxHash / RapidHash, and two of those collide with the BCL.
using System.IO.Hashing;
using jaytwo.StableHashing;
var incremental = new XxHash3(); // System.IO.Hashing, no alias needed
incremental.Append(buffer);
ulong key = StableHash.Default.HashData("hello"); // same algorithm, one shot
Which algorithm?
Use StableHash.Default (XXH3) for almost everything. Same digest on every platform, including scalar and SIMD implementations. Seed 0 is pinned for the life of 1.x. Keep all 64 bits when you can store them and narrow only where the destination is an int - 32 bits starts colliding around 65k distinct inputs where 64 bits holds out to billions.
Use StableHash.RapidHash when another runtime already standardized on RapidHash V3 (WASM, a JS/Rust/C port without a trusted XXH3, a system that avoided XXH3 because of code size or SIMD dispatch). Match version and seed, or the numbers will not match. Do not pick RapidHash on .NET because it "might be faster"; for this library's one-shot key/id use, XXH3 via System.IO.Hashing is the default for a reason. XXH3 itself is portable; RapidHash is the right choice when the other side already chose it.
Use StableHash.XxHash only to match classic XXH32/XXH64 already in the wild.
Algorithms
| Family | 32-bit | 64-bit | 128-bit | Seed | Notes |
|---|---|---|---|---|---|
| XXH3 | narrow the 64-bit | StableHash.XxHash3.Hasher64 |
StableHash.XxHash3.Hasher128 (net7.0+) |
ulong |
Hasher64 is StableHash.Default, pinned at seed 0. XXH3 is a 64-bit function; for 32 bits call Narrow32() on it. Calls System.IO.Hashing |
| xxHash | StableHash.XxHash.Hasher32 |
StableHash.XxHash.Hasher64 |
- | uint / ulong |
XXH32 and XXH64 are two different algorithms, so the 32-bit output is not the low half of the 64-bit output and the seed widths differ. This is the only genuinely 32-bit hasher here. Calls System.IO.Hashing |
| RapidHash | narrow the 64-bit | StableHash.RapidHash.Hasher64 |
- | ulong |
RapidHash V3, implemented here. 64-bit only, narrowed the same way as XXH3 |
The table lists the unseeded properties; each has a matching CreateHasherNN that takes the seed in the Seed column. Every member returns an IHash32 / IHash64 / IHash128; the corresponding adapter types (XxHash3Hasher, XxHash32Hasher, RapidHashV3Hasher, ...) are public if you would rather construct one directly. The XXH3 and xxHash adapters call System.IO.Hashing directly. Adapter type names (*Hasher) do not clash with it on a plain using; using static StableHash imports the nested family names and will collide.
32 bits from a 64-bit hasher
Narrow32() is the one way to 32 bits of a 64-bit hash. It is a plain truncation - the low 32 bits of the digest, bits 0-31, exactly what (uint) of the 64-bit value gives. Nothing is folded or re-mixed, so the 32-bit hex is literally the low half of the 64-bit hex.
IHash32 narrowed = StableHash.Default.Narrow32();
ulong full = StableHash.Default.HashData("hello"); // 0x9555E8555C62DCFD
uint low = narrowed.HashData("hello"); // 0x5C62DCFD
int signed = narrowed.HashDataAsInt32("hello"); // same bits, signed
string hex = narrowed.HashDataAsHex("hello"); // "5c62dcfd"
narrowed.Name // "xxh3-64-32"
StableHash.XxHash3.CreateHasher64(7).Narrow32().Name // "xxh3-64-32;seed=7"
It returns an IHash32, so everything on IHash32Extensions - string input, hex, signed, forcePositive - comes with it at the narrower width. That is why there are no 32-bit members on IHash64 itself: a value narrowed there would carry a Name claiming the full 64 bits, and it would be a second route to the same bits.
Note where the suffix lands on that last line: on the algorithm segment, in front of the parameters, not xxh3-64;seed=7-32. That is the detail Narrow32() exists to get right for you.
Half the digest is half the entropy: collisions get likely near 2^16 (~65k) distinct inputs rather than 2^32. Narrow when the destination is 32 bits wide, and keep all 64 bits otherwise.
Narrow32() is cheap to call repeatedly. Every hasher in this library caches its own narrowed view, so StableHash.Default.Narrow32() hands back the same instance every time and the headline call allocates nothing:
int id = StableHash.Default.Narrow32().HashDataAsInt32(value); // no allocation
A hasher you wrote yourself gets a fresh wrapper per call instead - correct, just not free - so hold the result if you narrow one of those in a loop.
Algorithm names
Every hasher reports a canonical Name. Persist it next to the values it produced, so that a stored hash can say which function made it: schema migration, running two algorithms during a rollout, and debugging a cache that suddenly misses all need it. ToString() returns the same string.
StableHash.Default.Name // "xxh3-64"
StableHash.XxHash3.Hasher128.Name // "xxh3-128" (net7.0+)
StableHash.XxHash.Hasher32.Name // "xxh-32"
StableHash.XxHash.Hasher64.Name // "xxh-64"
StableHash.RapidHash.Hasher64.Name // "rapidhash-v3"
StableHash.XxHash3.CreateHasher64(7).Name // "xxh3-64;seed=7"
new XxHash3Hasher(seed: 7).Name // "xxh3-64;seed=7"
Resolve a stored name with the method for its output width. The result has the same name and produces the same digest; a narrowed name resolves to a narrowed hasher.
IHash64 restored = StableHash.FromName64("xxh3-64;seed=7");
IHash32 restoredLow = StableHash.FromName32("xxh3-64-32;seed=7");
IHash128 restoredWide = StableHash.FromName128("xxh3-128;seed=7"); // net7.0+
if (StableHash.TryFromName32(storedName, out IHash32 hasher))
{
uint value = hasher.HashData("hello");
}
FromName32 / FromName64 / FromName128 throw for unknown or noncanonical names;
their TryFromNameNN counterparts return false. Names are case-sensitive, and seed 0
must use the bare name rather than ;seed=0. The 128-bit methods require net7.0+.
A hasher you implemented yourself is not in this lookup; keep the instance (or your
own name map) next to the stored name.
The convention, documented normatively on IHash32.Name:
A name identifies the output, not the implementation. Two instances that produce identical digests for every input report the same name, and any change that alters a digest changes the name. Architecture, SIMD path and build configuration never appear: XXH3 is
xxh3-64whether it ran on NEON, AVX2 or scalar code. (Architecture belongs in a name only where it genuinely changes the digest, as in multicodec'smurmur3-x64-128vsmurmur3-x86-128.)Spelling is lowercase
[a-z0-9-], taken from the multicodec table where an entry exists (xxh-32,xxh-64,xxh3-64,xxh3-128), and otherwise from RFC 6920 / the IANA Named Information registry, whose truncation suffix is<algorithm>-<bits>:sha-256-128is SHA-256 truncated to 128 bits, so the low 32 bits of XXH3-64 isxxh3-64-32. A version segment appears when versions differ in output (rapidhash-v3). These names borrow multicodec's vocabulary only; they are not multihashes and carry no varint code or length prefix.Parameters use MIME parameter syntax, spelled the way
text/plain;charset=utf-8is:xxh3-64;seed=7. That delimiter is safe in file names, URLs and database columns and does not collide with the-that carries structure. Seed 0 is the meaning of the bare name and is always omitted, soxxh3-64;seed=0is never produced.Truncation gets its own name, and
Narrow32()builds it.xxh3-64narrows toxxh3-64-32, andxxh3-64;seed=7toxxh3-64-32;seed=7- the suffix joins the algorithm segment, in front of the parameters. SinceNarrow32()is the only route to 32 bits of a 64-bit hash, there is no way to end up holding a narrowed value whose name claims the full width.
A name identifies exactly one function at one width, which is what you want next to a stored value. A name that has shipped never changes meaning.
Notes
StableHash.Defaultis XXH3 seed 0, 64-bit (xxh3-64). That choice is part of the stability contract and will not change in 1.x. Name a family member to select anything else.- The primary use case is a
GetHashCodethat survives across processes and deployments, andGetHashCodeis anint-StableHash.Default.Narrow32().HashDataAsInt32(value), which allocates nothing because each hasher caches its narrowed view. The default is still the full 64-bit function, because narrowing is a view over an output rather than a different algorithm, and theintis the low 32 bits of that digest. - There is no built-in 32-bit XXH3 or RapidHash hasher. Those families are 64-bit functions, and 32 bits of them is a truncation rather than a second algorithm, so you get it by calling
Narrow32()on the 64-bit hasher. One narrowing implementation serves everyIHash64, including yours - no per-algorithm low-32 type to keep in sync, and no 32-bit members onIHash64competing with it.StableHash.XxHash.Hasher32stays a real hasher because XXH32 genuinely is its own 32-bit algorithm, and its output is not the low half of XXH64 or of any XXH3 digest. - Nothing pairs two widths into one object. Each hasher is one function at one width with its own seed, so there is no way to build a single "hasher" whose halves disagree. It also means no width implies a relationship to another:
StableHash.XxHash.Hasher32is XXH32 and claims nothing aboutHasher64. - Seeds follow the algorithm, not the output width.
StableHash.XxHash.CreateHasher32takes auint(XXH32 is natively 32-bit seeded); every other member takes aulong. Seeding and narrowing compose: narrow a seeded hasher and you get the low 32 bits of the seeded digest. HashDatais the core member onIHash32/IHash64/IHash128.HashDataAsHexandHashDataAsInt32/HashDataAsInt64/HashDataAsInt128are extensions on those same interfaces, so every implementation gets formatted output for free, 128-bit included.forcePositiveclears the most significant bit before casting to a signed type; it narrows the range slightly (31, 63 or 127 effective bits) but avoids negative values where they are not allowed (database IDs, modulo bucketing).ReadOnlySpan<byte>is the core overload.byte[]null-guards then hashes;stringoverloads encode then hash. One-shot APIs never return byte arrays (endianness is left to the caller viaBinaryPrimitivesif needed).IHash128,XxHash128HasherandStableHash.XxHash3.Hasher128requireUInt128and are compiled only for net7.0+ (currently thenet8.0TFM).- A plain
usingof bothjaytwo.StableHashingandSystem.IO.Hashingdoes not clash, so you can import both in one file without an alias.using static StableHashimports the nested family namesXxHash3/XxHash/RapidHash, andXxHash3/XxHashcollide with the BCL. The XXH3 and xxHash adapters callSystem.IO.Hashingdirectly; RapidHash is the only algorithm implemented here, because it has no BCL twin.System.IO.Hashingowns streams, files and incremental hashing.
Made with ♥ by Jake - Licensed under the MIT License
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 is compatible. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 is compatible. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETFramework 4.6.2
- System.IO.Hashing (>= 8.0.0)
-
.NETStandard 2.0
- System.IO.Hashing (>= 8.0.0)
-
net6.0
- System.IO.Hashing (>= 8.0.0)
-
net8.0
- System.IO.Hashing (>= 8.0.0)
NuGet packages (2)
Showing the top 2 NuGet packages that depend on jaytwo.StableHashing:
| Package | Downloads |
|---|---|
|
jaytwo.Ergonomics.Ado
Transparent ergonomic extensions for base ADO.NET objects. |
|
|
jaytwo.Ergonomics.Logging
Ergonomic structured logging builders for Microsoft.Extensions.Logging. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.1.0-beta-20260927074007 | 56 | 9/27/2026 |
| 0.1.0-beta-20260915080614 | 82 | 9/15/2026 |
| 0.1.0-beta-20260407094328 | 83 | 4/7/2026 |
| 0.1.0-beta-20260407092806 | 67 | 4/7/2026 |