---
id: 69
title: "Register the query shape, don't send it"
kind: note
status: current
date: 2026-08-30
ai_assisted: true
authors:
  - "Theo Zourzouvillys"
tags: [api, design, architecture, interop]
references:
  - id: relay
    title: "Relay — Persisted Queries"
    url: https://relay.dev/docs/guides/persisted-queries/
    abstract: "Relay's compiler converts every query and mutation operation text in the application to an md5 hash at build time; the client then sends a `doc_id` instead of the operation text, and the server looks the text up in a query map. The documentation notes this lets the server allowlist queries, restricting the operations a client can execute."
  - id: safelist
    title: "Apollo GraphOS — Persisted Queries"
    url: https://www.apollographql.com/docs/graphos/routing/security/persisted-queries
    abstract: "Describes a persisted query list as an allowlist of trusted operations: the router fetches the list and enforces safelisting against it, and can optionally require clients to send operation IDs rather than full operation strings."
  - id: prepare
    title: "PostgreSQL — PREPARE"
    url: https://www.postgresql.org/docs/current/sql-prepare.html
    abstract: "Creates a server-side prepared statement. The statement is parsed, analysed and rewritten once when PREPARE runs, so the work that would otherwise be repeated on every submission of the same statement is done once and reused across executions."
  - id: partial
    title: "Google AIP-157 — Partial responses"
    url: https://google.aip.dev/157
    abstract: "Covers giving a caller control over what a large or expensive resource returns, by two routes: a field mask passed as a system parameter — query string, header or metadata — for fine-grained control over which fields come back, and a view enumeration offering predefined scopes such as BASIC and FULL. Selection within a shape the service already owns."
summary: "GraphQL is right that a developer should declare the fields and shape they want, and wrong about when. Register that document with the server instead of sending it: it can then be planned and indexed for, and the registered set is a schema you generate the caller's own SDK from."
supersedes: null
superseded_by: null
aliases: []
crossrefs:
  ZFN-51: "Where the registration id and its variables actually ride. The shape moves out of the request body, so the request needs a defined place to name which shape it is invoking."
  ZFN-14: "The rule this note reroutes. Generating clients from a schema is settled; the argument here is about which schema you generate from once the shapes are known per caller."
  ZFN-66: "Why the friction of a registration step is the right kind of friction. A query shape is a boundary other people stand on, which is exactly where deliberation is meant to be spent."
  ZFN-13: "What an unregistered shape costs you. A query whose plan you first see at execution time is work the caller sizes and you absorb."
  ZFN-53: "The same exposure read as abuse rather than as load. An arbitrary-shape endpoint lets the cheap side of the transaction choose how expensive the expensive side is."
  ZFN-62: "The ordering that makes a registry safe to change. Shapes go out before the client that uses them and are withdrawn after the last one stops calling."
  ZFN-47: "What a registry becomes once someone else's build depends on it. A registered shape is a published contract, not a cache entry."
  ZFN-67: "The checklist this note fills a gap in. It argues that customer-extended schema has to be a day-one decision; registration is the mechanism that lets those extensions be planned and typed rather than dumped in a blob."
---

## TL;DR

**Keep GraphQL's declaration; change when it arrives.** The developer still writes down the fields
they want and the shape to get them in. That document is registered with the server ahead of traffic
instead of travelling on every request.

- A shape the server holds before the first call is one it can compile, plan, index for and price. A
  shape that arrives with the request is one it can only defend itself against.
- Development, test and staging stay fully dynamic — change the query on every request, or post the
  whole document to an endpoint that just answers it. Registration is what happens on the way to
  production, and it is a build step rather than something a person does by hand.
- Registering is not instant. It may build an index or materialise a projection over a lot of
  existing data, so a shape has a readiness state and a client cannot call a version until it
  reports ready. This is closer to a schema migration than to adding a row to a safelist.
- The registered shapes are themselves a schema: a closed, typed surface describing what one caller
  actually asks for. Generate that caller's SDK from it — and because every client has declared the
  schema it expects, compatibility becomes something you can check rather than something you hope
  holds.
- The prior art is persisted queries and trusted documents. What tends to be left on the table is
  treating registration as the moment the server *learns the shape*, rather than as a bandwidth
  saving with a safelist attached.

## Context

There are two ends of an axis here and both of them are uncomfortable.

At one end the server owns the shape outright. Endpoints return what they return, and a client that
needs a slightly different view of the same data either waits for a server change or fetches the
whole object and discards most of it. That position never holds, because the pressure on it is
constant and the release valve is cheap: an `expand` parameter here, an `include` and a `fields`
there, and a few years later there is a query language in the codebase that nobody designed, nobody
wrote down, and nobody can plan against. [Partial responses](ref:partial) are the disciplined version
of that valve — a field mask, or a named view like `BASIC` and `FULL` — and they reach only as far as
selecting within a shape the service already owns.

At the other end the caller owns it. GraphQL's insight — the part I think is straightforwardly right
— is that the developer knows the shape they want and should be able to state it: these fields, this
nesting, this list bounded this way. What it also does is put that statement on the wire. Every
request then carries a query the server is meeting for the first time. It cannot have planned for it,
it cannot have built an index for it, and it cannot say what it will cost until it is already paying.
Depth limits, complexity scoring and query budgets are all attempts to bound something whose shape
you learn at the moment you have to answer it (ZFN-13, ZFN-53).

The declaration and the delivery are separable, and nearly all of the value is in the declaration.

## Recommendation

**Have the developer declare the shape, then register it with the server rather than sending it.**
The authored artefact looks like the document it replaces — the fields, the nesting, the shape of the
response — and it is written by the same person for the same reason. It simply arrives before the
traffic does, gets a stable id, and the request carries that id and its variables (ZFN-51).

### The registration step

**A shape held in advance is a shape you can do the expensive work on once.** This is what a database
does with a [prepared statement](ref:prepare): parse it, resolve it against the schema, choose a plan,
keep the plan. Registration moves parsing, validation, authorization analysis and planning off the
hot path and onto a shape you can take your time over. A join gets planned as a join instead of
discovered as an N+1 walk at request time, because there is a moment at which the whole traversal is
visible and nothing is waiting on it.

**Registration is the last honest moment to say no.** A registered shape can be checked against the
indexes that actually exist, and the missing one becomes a build failure rather than a slow endpoint
somebody finds in a dashboard a month later. It can be costed once and carry that cost as a quota,
which is a far better position than scoring a stranger's query while it runs. It can be checked
against what the caller is permitted to see, once, rather than field by field on every traversal.
None of that is available when the shape and the data arrive together.

**Registering a shape is asynchronous, and the shape has a state.** Accepting a registration is not
the same as being ready to answer it. Preparing one may mean building an index, or materialising a
projection across a large amount of data that already exists, and that is minutes or hours rather
than milliseconds. So a shape moves through submitted, preparing and ready, and a client cannot call
a version until that version reports ready — which makes registration itself a long-running
operation, with a handle to poll or subscribe to rather than a response to wait on (ZFN-67). I think
this is the part that catches people out, because every intuition borrowed from safelisting says
registration is a write to a list. It behaves far more like a schema migration, and it wants the same
respect: start it early, watch it, and expect the occasional one to be genuinely slow.

**This is persisted queries, generalized.** [Relay](ref:relay) has extracted operations at build time
and shipped hashes for years, and [safelisting](ref:safelist) the registered set is a standard
production posture. I am not claiming novelty in the mechanism. The claim is about what it is *for*:
these are usually presented as a transport optimization with a security benefit attached, where the
server's job is to look the text up and then behave exactly as it would have. Treat registration
instead as the point at which the server learns the shape, and what the server does on the other side
of it is different work.

### Development and production

**Ad-hoc querying is the correct development experience, so keep it.** Exploring a data model, a
one-off answer to a support question, a console somebody opens twice a year — all of these want an
unregistered query, and the reasons to restrict them are absent before production. Development, test
and staging should run fully dynamic: the client sends its query document with the request and is
free to change it between one call and the next, and there is an endpoint that takes a whole document
and simply answers it. Nothing is registered, nothing is pinned, nothing has to be prepared in
advance. That is GraphQL, and in that environment GraphQL is the right thing to be.

**Registration is therefore a promotion step.** It is what a shape goes through on its way to
production, not a tax on writing one. The developer iterates dynamically until the shape is the shape
they want, and registering it is how they say they are done — which is a much easier thing to ask for
than "declare everything up front", and gets you the same artefact at the end.

**The gap between those two modes is what will hurt, so close it mechanically.** A shape that worked
in a console yesterday and returns an error in production today is the obvious failure, and it will
happen on the day somebody is in a hurry. So registration belongs to the build: the same pass that
generates the client extracts the shapes and publishes them, and nobody registers one by hand
(ZFN-14). A shape missing in production is then a build that did not run, which is a class of problem
with existing habits around it.

**For a public API, the third party registers too.** That is more friction than accepting any query
anyone sends, and I think it is friction in the right place — what you get back is a per-customer
contract that concretely exists, rather than one you reconstruct from traffic on the day you want to
change something. It also means "who is still using this field" is answered by a lookup instead of a
log query.

### Versions and views

**Every client declares the schema it expects, so compatibility stops being a guess.** A registered
shape names the version it was written against, and the registry therefore knows — precisely, per
client, rather than in aggregate — what each caller depends on. Backward compatibility becomes a
check instead of a policy: a field cannot be withdrawn while a registered shape still references it,
and that is answered when the withdrawal is proposed rather than by a log query and a hopeful email.
Forward compatibility is the same fact read the other way. A client pinned to a shape keeps being
served that shape while the schema underneath it moves, because what it asked for is written down
somewhere the server can honour.

**One client is several views, and each should be registerable on its own.** A list, a detail screen,
a settings page and a nightly export all read the same resources through quite different shapes, and
collapsing them into one registration per application throws away everything the split is good for.
Keep the view as the unit. Then each one is costed on its own, versioned on its own, and rolled
forward on its own — and one view that is still building an index does not hold back the four that
are ready.

### The schema that falls out

**The registered set is a schema in its own right.** The service schema describes everything the
system could be asked; a caller's registered shapes describe what that caller does ask. The second is
much smaller, closed and fully typed, and it did not exist as an artefact until the registration step
created it.

**Generate the caller's SDK from the caller's schema.** ZFN-14 argues for generating clients from a
machine-readable schema and I still hold all of it; this changes which schema you feed in. A client
generated from the service schema is mostly surface one caller will never touch — optional fields,
variants, resources they have no access to — and the ergonomics problem that follows is why people
hand-write wrappers around generated clients in the first place. Generate from the registered set and
the result is that caller's own types, carrying the fields they asked for and nothing else, with a
compile error when a shape they registered is withdrawn.

**It gives per-tenant extension somewhere to land.** ZFN-67 argues that customers extending the
schema is a day-one decision rather than something to bolt on later, and this is the half of it that
makes the extensions usable: a customer who has defined their own fields writes shapes that reference
them, and those fields are typed in that customer's SDK on the same path as everything else, instead
of being reachable only through an untyped blob nailed to the side of the response.

## Consequences

**Easier:**

- Cost is knowable before traffic rather than estimated during it, and the estimate has something
  stable to attach itself to.
- Index coverage becomes a build-time property. "Is there an index behind this" acquires an owner and
  a moment.
- The safelist is a by-product. You did not build an allowlist; you have one.
- SDKs get small and specific, which is mostly a documentation win — the surface a developer reads is
  the surface they use.
- Compatibility is computable. Because every client has declared the shape and the schema version it
  expects, "can this field go yet" is a question you ask the registry rather than infer from traffic.

**Harder:**

- You now operate a registry, and a registry is state with a lifecycle: register, supersede,
  garbage-collect. Shapes nobody calls still consume plans, indexes and review attention until
  removing them is somebody's job.
- Rollout gains an ordering constraint and a clock. Shapes go out before the client that calls them
  and are withdrawn after the last caller stops — expand/migrate/contract wearing a different hat
  (ZFN-62) — and because preparing one can take minutes or hours, registering early stops being good
  manners and becomes a release requirement. A deploy that ships a client ahead of its shapes is a
  deploy that fails in production having passed everywhere else.
- The development/production asymmetry is a real trap and stays one. Closing it mechanically reduces
  how often it fires, not how sharp it is.
- Emergency queries need a designed answer. Somebody will need an unregistered shape while an
  incident is in progress, and "no" is not it — an audited, privileged, rate-limited ad-hoc path is.

**New obligations:**

- A registered shape is a published contract and has to be treated like one, including deprecation
  with a date and telemetry that proves the last caller has gone (ZFN-47).
- *Submitting* a registration has to be fast and boring, even though preparing one need not be.
  Everything above assumes developers reach for it without thinking, and if registering a shape takes
  a ticket they will design around it and you get four shapes where one would have done. Slow is
  fine; slow and manual is not.
- A shape that is preparing is a normal state, so everything that touches one has to have an answer
  for it — the deploy that is waiting, the dashboard that shows it, and the developer who wants to
  know whether it is stuck or merely large.
- Somebody owns the answer to "why was this shape rejected", and it has to be legible at the moment
  of rejection rather than reconstructible afterwards.

## References

- [PostgreSQL — PREPARE](ref:prepare) — the mechanical analogy, and the reason this is less a new
  idea than an old one applied a layer up.
- [Relay — Persisted Queries](ref:relay) — build-time extraction and hashing, with allowlisting named
  as the security benefit.
- [Apollo GraphOS — Persisted Queries](ref:safelist) — the registered set enforced as a safelist at
  the router.
- [Google AIP-157 — Partial responses](ref:partial) — the far end of the axis: field masks and named
  views, selecting within a shape the service owns.
- [ZFN-14](/zfn/14-schema-first-apis-generate-clients/) — schema-first and generated clients. This
  note is about which schema.
- [ZFN-51](/zfn/51-design-the-request-envelope-first/) — where the shape id and its variables ride.
- [ZFN-66](/zfn/66-agonize-over-the-interface/) — why a step that slows a boundary change down is the
  right place to spend deliberation.
- [ZFN-13](/zfn/13-load-shedding-and-flow-control/) /
  [ZFN-53](/zfn/53-attack-the-unit-economics-of-abuse/) — what an unplannable query costs, read as
  load and read as abuse.
- [ZFN-62](/zfn/62-expand-migrate-contract/) — the ordering that makes withdrawing a shape safe.
- [ZFN-67](/zfn/67-the-api-id-build-today/) — the wider checklist, including the case for treating
  customer-extended schema as a day-one decision.

## Changelog

- **2026-08-30**: Amended. Development and staging run fully dynamic, with an endpoint that answers a
  whole query document, so registration is a promotion step rather than a tax on writing a shape;
  registering is asynchronous and a shape carries a readiness state; per-client declared schemas make
  backward and forward compatibility checkable; views are registered individually rather than one
  registration per client.
- **2026-08-30**: First published as a Field Note.
