gqlhash
August 6, 2026 · View on GitHub
gqlhash
gqlhash generates SHA-256 (and other) hashes from GraphQL executable documents ignoring comments, differences in formatting, and optionally input values and variables.
It's shipped as:
- The Go package github.com/romshark/gqlhash/v2 for fast GraphQL request document hashing.
github.com/romshark/gqlhash/v2/cmd/gqlhasha CLI tool for scripts and CI pipelines.github.com/romshark/gqlhash/v2/cmd/gqlhash-proxy, a fast allowlist-firewall proxy you can put in front of a GraphQL API.
Generating a gqlhash is faster than parsing a document into an AST and comparing the ASTs.
The hash is a fingerprint of a document: it groups equivalent operations in analytics and serves as a cache key. See use cases.
Warning
You probably shouldn't use the proxy if your stack already supports trusted documents. Taking an ID and looking it up costs less than hashing a document, however little that costs. The proxy is for the stacks that don't support them. It requires nothing of the client or the API and enforces an allowlist over ordinary GraphQL traffic. Trusted documents are likely better on every other axis: bandwidth, CPU per request, etc.
Important
See MIGRATION.md for migrating from v1 to the new v2.
On a 24-thread Xeon, sharing the machine with the load generator that drives it, gqlhash-proxy turns away ~928,000 unknown documents a second at a median of 140 µs, and forwards ~211,000 allowed ones.
gqlhash-proxy at GOGC=800, wrk at 200 connections for 20s, generator and proxy on the same Xeon w5-2455X — 12 physical cores, 24 hardware threads:
| rejected | forwarded | |
|---|---|---|
| req/s | ~928,000 | ~211,000 |
| median latency | 140 µs | 0.94 ms |
| p99 latency | 2.75 ms | 4.27 ms |
| CPU per request | 18 µs | 66 µs |
| cores held by the proxy | 16.8 of 24 | 13.9 of 24 |
| cores busy on the machine | 23.0 of 24 (96%) | 21.4 of 24 (89%) |
| RSS peak / mean | 202 / 111 MB | 168 / 160 MB |
Benchmarking Details
The results above are a median of three runs. A rejection never opens an upstream connection, which is where the four-fold difference comes from.These are numbers from a contended machine, and they understate the proxy. The load generator and the sample API run on the same 24 hardware threads, so the proxy never had the box to itself: rejecting, it held 16.8 while wrk took 6.1 and the machine ran out at 96%; forwarding, the sample API alone cost 5.7. What a figure worth quoting needs is a second machine driving the load. go run ./internal/cmd/loadtest reproduces exactly the above, contention included, and prints what each of the three held so a run starved by its own generator is visible in its output.
With -ignore=variables the following two documents produce the same SHA-256 hash, despite differing in formatting, comments, input values and variables:
{
object(x: 42, y: 1.0) {
id
name
description @translate(lang: [DE, EN])
blockstring(s: """gqlhash parses block string values
and doesn't care about formatting.""")
}
}
query (
$x: Int
$y: Float
$langs: [Language!] # Prefer German, if possible.
$text: String
) {
# Some comment
object(x: $x, y: $y) {
id
name # We will need this.
description @translate(lang: $langs)
blockstring(s: $text)
}
}
gqlhash implements the GraphQL specification of September 2025.
-ignore=inputs ignores input values, so the following two documents produce the same hash despite differing argument values and value types:
{ object(x: 42, y: 1.0) { id } }
{ object(x: 7, y: "hello") { id } }
Both produce the same hex-encoded SHA-256 hash 03f3ba19742426404ddcf6467c7f210472f973d3cc78110ebf4ca94a2326e50d.
-ignore=variables ignores what -ignore=inputs ignores and the variables on top of that, both their definitions and their usages. A parameterized document then matches its inline-value equivalent:
query ($x: Int) { object(x: $x) { id } }
{ object(x: 42) { id } }
Both produce the same hex-encoded SHA-256 hash 21de2f6884f4e7e93a6c45c729b1d6fc47a73c4e657b9b1bf18923efcc27501a.
Use cases
Trusted documents
The gqlhash-proxy implements trusted documents, also known as persisted queries or a query allowlist, keeps the hash of every document it accepts and rejects everything else. The client sends a document, the server hashes it and looks the hash up.
The hash ignores formatting, so a client that reformats, minifies or re-indents a document keeps the hash it was registered under. Without that, the allowlist has to store the document byte for byte and every layout change is a new entry.
Cache keys
The hash of a document is a key for a query plan cache or a response cache. Two clients that send the same document formatted differently share the entry.
Keep the default -ignore=nothing: under the other modes documents differing in their values hash alike, and a cache would answer one with another's response. The hash covers the document alone, so a response cache key needs the variables and the operation name too.
Grouping operations
With -ignore=inputs documents that differ only in their literal values share a hash, which groups them in logs and metrics by shape rather than by argument.
Installation
Homebrew
macOS only: both are casks, and Homebrew installs a cask nowhere else. On Linux take a released binary or build from source, below.
brew tap romshark/tools
brew trust romshark/tools # Homebrew 6 loads a third-party tap only once trusted
brew install gqlhash # the hashing command
brew install gqlhash-proxy # the allowlist-firewall proxy
Without the trust line Homebrew 6 stops at Refusing to load cask romshark/tools/gqlhash from untrusted tap. brew trust --cask romshark/tools/gqlhash trusts that one cask instead of every one the tap
carries, now and later.
v1 shipped gqlhash as a formula, which Homebrew doesn't replace with a cask.
brew uninstall --formula gqlhash first if brew list --formula still names it.
Compiled Binary
Download a compiled binary from GitHub Releases.
Container Image
The gqlhash-proxy only, for linux/amd64 and linux/arm64:
docker pull ghcr.io/romshark/gqlhash-proxy:2
See details at the proxy's README.
From Source
go install github.com/romshark/gqlhash/v2/cmd/gqlhash@latest
go install github.com/romshark/gqlhash/v2/cmd/gqlhash-proxy@latest
This requires the latest version of Go.
Usage: gqlhash
Important
The gqlhash CLI spawns a process per invocation. It's for scripts, CI pipelines and local use, not for a per-request path. Use the gqlhash-proxy for filtering incoming requests. A Go server may use the package functions (Compare, AppendHash).
gqlhash reads the document from stdin until EOF and prints its SHA-256 hash as a hexadecimal string to stdout:
# prints: d592c23e0c362a3a49b4c4b18316d9bfc5bda2ce7577b0925d25c4b4cba9c2ec
echo '{foo bar}' | gqlhash
To print the version:
gqlhash -version
gqlhash v2.0.0
Copyright (c) 2026 Roman Scharkov (github.com/romshark/gqlhash)
MIT License
gqlhash-proxy -version answers the same way, so a script reads the version off either with -version | head -1. For the build behind it — the Go version, the module and every dependency — ask the Go toolchain, which prints it for any Go binary:
go version -m $(command -v gqlhash)
File Input
-file reads the document from a file instead:
gqlhash -file ./executable_document.graphql
Output Format
The supported output formats:
hex(hexadecimal string)base32(base32 encoding as defined in RFC 4648)base64(base64 encoding as defined in RFC 4648)base64url(URL-safe base64 encoding as defined in RFC 4648 §5)
The default is hex. -format selects another one:
# prints: 1ZLCPgw2KjpJtMSxgxbZv8W9os51d7CSXSXEtMupwuw=
echo '{foo bar}' | gqlhash -format base64
base64url avoids the + and / characters, which makes it the format for a URL, a header or a file name. This digest carries a +, where the two differ:
# prints: u3Pd9Iuuyzg+q1CF5y6zJa35kLIEs66EsP6CrHfUcE0=
echo '{foo}' | gqlhash -format base64
# prints: u3Pd9Iuuyzg-q1CF5y6zJa35kLIEs66EsP6CrHfUcE0=
echo '{foo}' | gqlhash -format base64url
Hash Function
The supported hash functions:
sha2(SHA-256)sha3(SHA3-512)blake2b(unkeyed)blake2s(unkeyed)blake3(unkeyed, 256 bits)sha1md5fnv(FNV-1, 64 bits)fnv1a(FNV-1a, 64 bits)xxh64(XXH64, unseeded)crc32(IEEE polynomial)crc64(ISO polynomial, defined in ISO 3309)
The default is sha2, which is also the proxy's and the narrowest thing an allowlist or a persisted-query registry needs of a hash: collision resistance. md5 and sha1 are broken and crc32, crc64, fnv, fnv1a and xxh64 are collidable by construction — reach for those to group or bucket documents, never to decide whether one may run.
-hash selects another one:
# prints: 9df882f0d75c115d9587c6afd10b6fbeb8d8865b75db3be73e079513667739e9
echo '{foo bar}' | gqlhash -hash blake3
Performance
Hashing testdata/big.graphql (2854 bytes), sorted fastest first.
-hash | time | throughput |
|---|---|---|
xxh64 | 2.04 µs | 1336 MB/s |
crc32 | 2.10 µs | 1297 MB/s |
sha1 | 2.39 µs | 1137 MB/s |
sha2 | 2.40 µs | 1135 MB/s |
crc64 | 2.72 µs | 999 MB/s |
blake2b | 3.26 µs | 834 MB/s |
fnv | 3.57 µs | 762 MB/s |
fnv1a | 3.62 µs | 751 MB/s |
blake3 | 3.77 µs | 721 MB/s |
md5 | 3.93 µs | 693 MB/s |
blake2s | 4.06 µs | 670 MB/s |
sha3 | 4.85 µs | 561 MB/s |
Measured with go test . -bench BenchmarkHashFunctions on an Apple M4 Pro, Go 1.26.5, GOMAXPROCS=1, over 8 runs.
Depth Limit
-depth-limit is how deeply selection sets, list values and input object values may nest before a document is refused. The default is 128, past what a document written for an API reaches and far below what one costs to attack with. Below 1 takes the default.
echo '{ a { b { c } } }' | gqlhash -depth-limit 2 # too deep
Ignoring Input Values
-ignore selects what to leave out of the hash: nothing (the default), inputs or variables. Each one leaves out what the one before it leaves out, and more.
-ignore=inputs ignores input values, so documents that differ only in their argument or default values, whatever the value type, hash alike:
# Both print the same hash.
echo '{ object(x: 42, y: 1.0) { id } }' | gqlhash -ignore=inputs
echo '{ object(x: 7, y: "hello") { id } }' | gqlhash -ignore=inputs
Variable usages are ignored like literals: object(x: $v) and object(x: 1) hash alike. The variable signature is kept, so query ($v: ID) differs from an operation that declares no variables.
Ignoring Variables
-ignore=variables ignores variables entirely, both definitions and usages, on top of what -ignore=inputs ignores. A parameterized document then matches its inline-value equivalent:
# Both print the same hash.
echo 'query ($x: Int) { object(x: $x) { id } }' | gqlhash -ignore=variables
echo '{ object(x: 42) { id } }' | gqlhash -ignore=variables
Usage: Proxy
See cmd/gqlhash-proxy/README.md for how to use the gqlhash-proxy to protect your GraphQL API using an allowlist of queries.
Performance
Measured against two references across the benchmark documents:
- Hashing the document bytes directly with SHA1, which does no parsing: gqlhash takes ~4x as long (min ~2.2x, max ~7.6x).
- Parsing the document into an AST with vektah/gqlparser/v2: gqlhash takes ~1/77 of the time (min ~1/20, max ~1/182). gqlhash allocates nothing, gqlparser/v2 allocates hundreds of times per document.
Results
goos: darwin
goarch: arm64
pkg: github.com/romshark/gqlhash/v2
cpu: Apple M4 Pro
BenchmarkReferenceSHA1/blockstring/minified/direct 25799564 45.95 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/blockstring/minified/gqlhash/nothing 6051105 197.0 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/blockstring/minified/gqlhash/inputs 7272676 169.6 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/blockstring/minified/gqlhash/variables 7205239 170.2 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/blockstring/minified/vektah 81962 14455 ns/op 10905 B/op 195 allocs/op
BenchmarkReferenceSHA1/blockstring/formatted/direct 25974072 46.24 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/blockstring/formatted/gqlhash/nothing 5082355 231.2 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/blockstring/formatted/gqlhash/inputs 5900920 200.9 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/blockstring/formatted/gqlhash/variables 5972416 198.1 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/blockstring/formatted/vektah 83458 14503 ns/op 10953 B/op 195 allocs/op
BenchmarkReferenceSHA1/tiny/minified/direct 34142316 35.10 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/tiny/minified/gqlhash/nothing 15186620 75.87 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/tiny/minified/gqlhash/inputs 15861388 77.08 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/tiny/minified/gqlhash/variables 15664713 77.08 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/tiny/minified/vektah 88830 13835 ns/op 9449 B/op 174 allocs/op
BenchmarkReferenceSHA1/tiny/formatted/direct 35161786 34.22 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/tiny/formatted/gqlhash/nothing 14199922 81.97 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/tiny/formatted/gqlhash/inputs 14837398 81.67 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/tiny/formatted/gqlhash/variables 14761720 81.73 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/tiny/formatted/vektah 90900 13597 ns/op 9449 B/op 174 allocs/op
BenchmarkReferenceSHA1/medium/minified/direct 14672617 83.77 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/medium/minified/gqlhash/nothing 2948643 406.8 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/medium/minified/gqlhash/inputs 3523322 341.6 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/medium/minified/gqlhash/variables 3515204 347.9 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/medium/minified/vektah 61765 20165 ns/op 17361 B/op 285 allocs/op
BenchmarkReferenceSHA1/medium/formatted/direct 8286560 147.6 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/medium/formatted/gqlhash/nothing 2219371 523.7 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/medium/formatted/gqlhash/inputs 2614382 456.7 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/medium/formatted/gqlhash/variables 2594966 453.4 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/medium/formatted/vektah 60288 20314 ns/op 17977 B/op 300 allocs/op
BenchmarkReferenceSHA1/big/minified/direct 2025945 588.2 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/big/minified/gqlhash/nothing 570358 2051 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/big/minified/gqlhash/inputs 731978 1641 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/big/minified/gqlhash/variables 823298 1469 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/big/minified/vektah 22948 51611 ns/op 53360 B/op 839 allocs/op
BenchmarkReferenceSHA1/big/formatted/direct 1339686 896.1 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/big/formatted/gqlhash/nothing 452890 2646 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/big/formatted/gqlhash/inputs 534379 2184 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/big/formatted/gqlhash/variables 585877 2028 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/big/formatted/vektah 22112 53901 ns/op 54880 B/op 877 allocs/op
BenchmarkReferenceSHA1/nesting_attack/minified/direct 1547923 781.1 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/nesting_attack/minified/gqlhash/nothing 204498 5959 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/nesting_attack/minified/gqlhash/inputs 252505 4723 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/nesting_attack/minified/gqlhash/variables 263535 4521 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/nesting_attack/formatted/direct 746440 1646 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/nesting_attack/formatted/gqlhash/nothing 141748 8337 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/nesting_attack/formatted/gqlhash/inputs 161920 7394 ns/op 0 B/op 0 allocs/op
BenchmarkReferenceSHA1/nesting_attack/formatted/gqlhash/variables 159295 7335 ns/op 0 B/op 0 allocs/op
PASS
ok github.com/romshark/gqlhash/v2 68.329s
Known Limitations
Descriptions are accepted where some parsers refuse them
The specification allows a description on an operation, a fragment and a variable definition, and this parser takes them. vektah/gqlparser, which many Go servers use, refuses all three. A document carrying one hashes and is forwarded here, and the API behind it may answer a syntax error.
Numbers are hashed as written
Formatting is left out of the hash and values are not: 1.0, 1.00, 1e2 and 100.0 each hash differently, where a reformatted document hashes the same. A client whose serializer rewrites a value writes a document the allowlist no longer holds, so pin what generates the documents rather than the numbers they carry. -ignore=inputs leaves values out entirely, which makes this moot at the cost of hashing by shape.
Order of Operations, Selections and Arguments
Everything is hashed in the order it appears, so moving anything around changes the hash:
{ user { id name } }
{ user { name id } } # a different hash
{ user(id: 1, role: ADMIN) { name } }
{ user(role: ADMIN, id: 1) { name } } # a different hash
{ search(where: {name: "ada", role: ADMIN}) { id } }
{ search(where: {role: ADMIN, name: "ada"}) { id } } # a different hash
query A { a } query B { b }
query B { b } query A { a } # a different hash
Fragment spreads and fragment definitions are hashed as they appear, not inlined. A document using a named fragment produces a different hash than its inlined equivalent, although both select the same fields:
{ user { ...userFields } }
fragment userFields on User { id name }
{ user { id name } }
Both would cost a pass over the document that hashing doesn't otherwise need — throughput spent on every request to buy something an allowlist never asks for, since a client sends the document it registered.
Development
See DEVELOPMENT.md for how to build and test this repository.