GraphQL gives clients enormous flexibility to request exactly the data they need, but that flexibility comes at a cost. Query strings can balloon to tens of kilobytes, and an open endpoint invites abuse from malicious or poorly constructed operations. Persisted queries solve both problems by replacing full query text with compact identifiers, cutting bandwidth and locking down your API surface in one move.
In a typical GraphQL setup, the client sends the entire query string to the server with every request. For a small { user { name } } query, that is not a problem. But real-world applications often produce query documents that run thousands of characters long, packed with fragments, nested fields, and variables. Every byte of that query travels over the wire on every single request, even when the server has already seen the exact same operation hundreds of times before. Persisted queries change that equation entirely.
What Are Persisted Queries?
A persisted query is a GraphQL operation that gets stored on the server and associated with a unique identifier, typically a SHA-256 hash of the query string. Once the query is persisted, the client can send just that short hash instead of the full operation text. The server looks up the hash, retrieves the stored query, executes it, and returns results as normal.
This approach delivers two categories of benefit. First, performance improves because the request payload shrinks dramatically. A query that would normally require several kilobytes of text gets replaced by a 64-character hex string. This matters especially on mobile networks and other bandwidth-constrained connections where the uplink speed from client to server is the slowest part of the chain. Second, security improves because the server can reject any operation it does not recognize. If the server only executes queries that exist in its persisted query store, an attacker cannot craft arbitrary operations to probe your schema, exfiltrate data through deeply nested queries, or launch denial-of-service attacks through computationally expensive operations.
The GraphQL specification itself does not define persisted queries. They are an extension to the protocol, implemented differently across servers and clients. The GraphQL-over-HTTP specification is currently working to standardize this feature under the name "persisted documents."
How the Protocol Works
The persisted query protocol follows a straightforward handshake pattern. Understanding this flow is essential before writing any implementation code.
In the simplest form, the client computes the SHA-256 hash of a query string at build time or at runtime. When making a request, it sends that hash to the server inside an extensions field rather than sending the full query text. The server receives the hash, checks its store for a matching query, and executes it if found.
When a server does not recognize a hash, the behavior depends on which flavor of persisted queries you are using. With Automatic Persisted Queries (APQ), the server responds with a "not found" error, prompting the client to resend the request with both the hash and the full query string. The server stores the query for future use and proceeds with execution. With registered persisted queries, an unknown hash simply gets rejected, as only pre-approved operations are allowed.
Here is what a persisted query request looks like as a raw HTTP GET request:
# The client sends a GET request with the hash in the extensions parameter
# GET /graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"abc123..."}}
And here is how the client falls back when the server has not seen the query before (APQ mode):
# Step 1: Client sends hash only
# Server responds: {"errors": [{"message": "PersistedQueryNotFound"}]}
# Step 2: Client resends with both hash and full query
# POST /graphql
# {
# "query": "query GetUser($id: ID!) { user(id: $id) { name email } }",
# "extensions": {
# "persistedQuery": {
# "version": 1,
# "sha256Hash": "abc123..."
# }
# }
# }
# Step 3: Server stores the query and executes it
# Future requests only need the hash
The ability to use GET requests is significant. Standard GraphQL operations use POST requests, which CDNs and browser caches typically do not cache. By converting operations to short GET requests with deterministic hash-based URLs, persisted queries make CDN caching straightforward for read operations.
Building a Persisted Query Server in Python
Let's build a simple server in Python that accepts query text from a client build tool and stores each query with a unique hash. This server acts as the registration endpoint during development, producing a JSON map that the GraphQL server will use at runtime.
import json
import argparse
from hashlib import md5
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from urllib.parse import parse_qs
class QueryMap:
"""Manages a persistent mapping of query hashes to query text."""
def __init__(self, file_path: Path):
self.file_path = file_path
self.queries = self._load()
def _load(self) -> dict:
if self.file_path.exists():
with self.file_path.open("r") as f:
return json.load(f)
self._save({})
return {}
def _save(self, data: dict = None) -> None:
with self.file_path.open("w") as f:
json.dump(data or self.queries, f, indent=2)
def register(self, query_text: str) -> str:
query_id = md5(query_text.encode()).hexdigest()
self.queries[query_id] = query_text
self._save()
return query_id
class PersistHandler(BaseHTTPRequestHandler):
"""Handles POST requests to register new queries."""
def do_POST(self):
content_length = int(self.headers["Content-Length"])
body = self.rfile.read(content_length).decode()
params = parse_qs(body)
text = params.get("text", [None])[0]
if text is None:
self._respond(400, "Missing 'text' parameter")
return
query_id = query_map.register(text)
self._respond(200, json.dumps({"id": query_id}),
"application/json")
def _respond(self, status, message, content_type="text/plain"):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.end_headers()
self.wfile.write(message.encode())
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=2999)
parser.add_argument("--host", type=str, default="127.0.0.1")
parser.add_argument("--file", type=str, default="query_map.json")
args = parser.parse_args()
query_map = QueryMap(Path(args.file))
server = HTTPServer((args.host, args.port), PersistHandler)
print(f"Persist server running on {args.host}:{args.port}")
server.serve_forever()
Run this server during development with python persist_server.py --file query_map.json. As your client build tool (such as Relay Compiler) encounters GraphQL operations, it sends each one to this server. The server hashes the query text, stores the mapping, and returns the ID. The result is a query_map.json file that looks like this:
# Contents of query_map.json after registration
# {
# "a1b2c3d4e5f6...": "query GetUser($id: ID!) { user(id: $id) { name email } }",
# "f6e5d4c3b2a1...": "query ListPosts { posts { id title author { name } } }"
# }
This JSON file becomes the lookup table your production GraphQL server uses to resolve incoming query hashes back to executable operations.
For production systems, consider storing the query map in a database like Redis or PostgreSQL rather than a flat JSON file. This enables atomic updates, better concurrency handling, and easier deployment across multiple server instances.
Integrating with Strawberry GraphQL
Strawberry is a modern Python library for building GraphQL APIs using type annotations. It supports schema extensions, which provide hooks into the query execution lifecycle. We can use an extension to intercept incoming requests, check for a persisted query ID, and swap the hash for the full query text before execution proceeds.
import json
from pathlib import Path
from collections.abc import AsyncIterator
from graphql import ExecutionResult, GraphQLError
from strawberry.extensions import SchemaExtension
class PersistedQueriesExtension(SchemaExtension):
"""Resolves persisted query IDs to full query text."""
def __init__(self, *, persisted_queries_path: Path):
self.cache: dict[str, str] = {}
with persisted_queries_path.open("r") as f:
self.cache = json.load(f)
async def on_operation(self) -> AsyncIterator[None]:
request = self.execution_context.context.get("request")
body = await request.json()
document_id = body.get("document_id")
if document_id is None:
# No persisted query ID provided
self.execution_context.result = ExecutionResult(
data=None,
errors=[GraphQLError(
"Query text not accepted. Provide a document_id."
)],
)
elif document_id not in self.cache:
# Unknown query ID
self.execution_context.result = ExecutionResult(
data=None,
errors=[GraphQLError("Unknown document_id.")],
)
else:
# Replace with the stored query
self.execution_context.query = self.cache[document_id]
yield
Wire this extension into your Strawberry schema and FastAPI application like so:
import strawberry
from pathlib import Path
from strawberry.fastapi import GraphQLRouter
@strawberry.type
class User:
name: str
email: str
@strawberry.type
class Query:
@strawberry.field
def user(self, id: strawberry.ID) -> User:
# Replace with your data source logic
return User(name="Ada Lovelace", email="ada@example.com")
schema = strawberry.Schema(
query=Query,
extensions=[
PersistedQueriesExtension(
persisted_queries_path=Path("query_map.json")
),
],
)
graphql_app = GraphQLRouter(schema)
# In your FastAPI app:
# app.include_router(graphql_app, prefix="/graphql")
With this setup, clients send a document_id field instead of a query field. The extension intercepts the request, looks up the query in the map, and execution proceeds normally. If a client sends an unrecognized ID or tries to send raw query text, the server rejects the request.
Automatic vs. Registered Persisted Queries
There are two distinct approaches to persisted queries, and choosing between them depends on your security requirements and the nature of your API.
Automatic Persisted Queries (APQ)
APQ is primarily a performance optimization. The server accepts any query it receives, stores it with its hash, and serves it from cache on subsequent requests. This approach is easy to adopt because it requires no build-time tooling to pre-register queries. Apollo Server supports APQ out of the box without additional configuration. However, APQ does not restrict which queries the server will execute. An attacker can still send arbitrary queries -- they just need to include the full query text on the first request. The server will happily persist and execute it.
Registered Persisted Queries (Safelisting)
Registered persisted queries take a stricter approach. All allowed operations are pre-registered during the build or deployment process. At runtime, the server only executes operations that exist in its persisted query list (PQL). Any request with an unrecognized hash gets rejected outright. This approach functions as an operation safelist, dramatically reducing the API's attack surface. The tradeoff is that it requires coordination between client and server deployments. Every time a client adds or modifies a query, the PQL must be updated before that query will work in production.
Registered persisted queries work best for first-party APIs where you control both client and server. If your GraphQL API is public-facing and consumed by third-party clients whose operations you cannot predict, safelisting is not a practical option. In that case, rely on other defenses like query depth limiting, cost analysis, and rate limiting.
Security Considerations
Persisted queries are one layer in a broader GraphQL security strategy. They complement but do not replace other important defenses.
By restricting execution to known operations, registered persisted queries prevent several categories of attack. Deeply nested cyclical queries designed to exhaust server resources get blocked because the attacker cannot register their malicious operation. Schema introspection probes from unauthorized clients are rejected if the introspection query is not in the PQL. Excessively broad field selections that attempt to extract more data than intended are stopped before execution begins.
However, persisted queries alone do not handle authorization. A valid persisted query executed by an unauthorized user can still return data they should not see. Field-level authorization, authentication middleware, and proper resolver-level access checks remain essential. Similarly, persisted queries do not protect against abuse through valid operations -- a legitimate query executed millions of times still requires rate limiting.
For a comprehensive defense, combine persisted queries with query depth limiting (rejecting operations nested beyond a certain threshold), query cost analysis (assigning complexity scores to fields and rejecting operations that exceed a budget), rate limiting at both the HTTP and GraphQL operation level, and disabling introspection in production environments.
Key Takeaways
- Persisted queries replace full query strings with compact hashes: The client sends a short identifier instead of potentially kilobytes of query text, reducing bandwidth usage and enabling HTTP GET caching through CDNs.
- Two flavors serve different needs: Automatic Persisted Queries (APQ) optimize performance by caching any query the server encounters. Registered persisted queries provide security by restricting execution to a pre-approved safelist of operations.
- Python implementation is straightforward: Using Strawberry GraphQL and FastAPI, you can add persisted query support through a schema extension that intercepts requests and resolves query IDs from a JSON map or database store.
- Safelisting is powerful but not universal: Registered persisted queries work well for first-party APIs where you control both client and server. Public APIs consumed by third-party clients need alternative security measures like cost analysis and depth limiting.
- Persisted queries are one piece of the puzzle: They reduce attack surface and improve performance, but a complete GraphQL security strategy also requires authentication, field-level authorization, rate limiting, and query complexity analysis.
GraphQL's flexibility is its greatest strength, but that flexibility needs guardrails in production. Persisted queries provide an effective mechanism for trimming request sizes and constraining what operations your server will execute. Whether you start with APQ for quick performance gains or go straight to registered queries for tighter security, the implementation path in Python is well-supported by libraries like Strawberry and tools like Relay. The key is choosing the approach that matches your API's threat model and client architecture.