jaytwo.StableHashing 0.1.0-beta-20260927074007

This is a prerelease version of jaytwo.StableHashing.
dotnet add package jaytwo.StableHashing --version 0.1.0-beta-20260927074007
                    
NuGet\Install-Package jaytwo.StableHashing -Version 0.1.0-beta-20260927074007
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="jaytwo.StableHashing" Version="0.1.0-beta-20260927074007" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="jaytwo.StableHashing" Version="0.1.0-beta-20260927074007" />
                    
Directory.Packages.props
<PackageReference Include="jaytwo.StableHashing" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add jaytwo.StableHashing --version 0.1.0-beta-20260927074007
                    
#r "nuget: jaytwo.StableHashing, 0.1.0-beta-20260927074007"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package jaytwo.StableHashing@0.1.0-beta-20260927074007
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=jaytwo.StableHashing&version=0.1.0-beta-20260927074007&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=jaytwo.StableHashing&version=0.1.0-beta-20260927074007&prerelease
                    
Install as a Cake Tool

jaytwo.StableHashing

NuGet Version NuGet Downloads License: MIT

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.

View source on GitHub

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[], and ReadOnlySpan<byte> to uint / ulong / UInt128 / hex / signed integers
  • Well-known NCHFs, with StableHash.Default pinned 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.Hashing already ships Crc32 and Crc64.

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

  • StableHash is a registry of well-known hashers grouped by family. StableHash.Default is XXH3 seed 0, 64-bit (xxh3-64), pinned for the life of 1.x.
  • The headline use case is a GetHashCode you can persist, and GetHashCode is an int: StableHash.Default.Narrow32().HashDataAsInt32(value). Narrow32() takes the low 32 bits of the digest and hands back an IHash32 named xxh3-64-32, so the narrowed value stays self-describing.
  • Every hasher is an IHash32, IHash64 or IHash128. 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.CreateHasher32 takes a uint because XXH32 is natively 32-bit seeded, while every other member takes a ulong.
  • 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[] and string guard/encode then hash. XXH3 and xxHash call System.IO.Hashing; RapidHash is implemented here.
  • Hexadecimal, signed, and forcePositive output on every interface through extensions, 128-bit included
  • Every hasher reports a canonical Name you can persist next to the value
  • UTF-8 encoding by default for strings; custom Encoding overloads 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-64 whether it ran on NEON, AVX2 or scalar code. (Architecture belongs in a name only where it genuinely changes the digest, as in multicodec's murmur3-x64-128 vs murmur3-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-128 is SHA-256 truncated to 128 bits, so the low 32 bits of XXH3-64 is xxh3-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-8 is: 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, so xxh3-64;seed=0 is never produced.

  • Truncation gets its own name, and Narrow32() builds it. xxh3-64 narrows to xxh3-64-32, and xxh3-64;seed=7 to xxh3-64-32;seed=7 - the suffix joins the algorithm segment, in front of the parameters. Since Narrow32() 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.Default is 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 GetHashCode that survives across processes and deployments, and GetHashCode is an int - 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 the int is 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 every IHash64, including yours - no per-algorithm low-32 type to keep in sync, and no 32-bit members on IHash64 competing with it. StableHash.XxHash.Hasher32 stays 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.Hasher32 is XXH32 and claims nothing about Hasher64.
  • Seeds follow the algorithm, not the output width. StableHash.XxHash.CreateHasher32 takes a uint (XXH32 is natively 32-bit seeded); every other member takes a ulong. Seeding and narrowing compose: narrow a seeded hasher and you get the low 32 bits of the seeded digest.
  • HashData is the core member on IHash32 / IHash64 / IHash128. HashDataAsHex and HashDataAsInt32 / HashDataAsInt64 / HashDataAsInt128 are extensions on those same interfaces, so every implementation gets formatted output for free, 128-bit included. forcePositive clears 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; string overloads encode then hash. One-shot APIs never return byte arrays (endianness is left to the caller via BinaryPrimitives if needed).
  • IHash128, XxHash128Hasher and StableHash.XxHash3.Hasher128 require UInt128 and are compiled only for net7.0+ (currently the net8.0 TFM).
  • A plain using of both jaytwo.StableHashing and System.IO.Hashing does not clash, so you can import both in one file without an alias. using static StableHash imports the nested family names XxHash3 / XxHash / RapidHash, and XxHash3 / XxHash collide with the BCL. The XXH3 and xxHash adapters call System.IO.Hashing directly; RapidHash is the only algorithm implemented here, because it has no BCL twin. System.IO.Hashing owns streams, files and incremental hashing.

Made with ♥ by Jake - Licensed under the MIT License

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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