Back to Blog
api documentation best-practices developer-tools

Documenting GraphQL APIs: what's different from REST

Your REST doc template breaks on GraphQL. Here's how to document schemas, queries, mutations, subscriptions, and the N+1 problem correctly.

Alain Sanchez
Alain Sanchez
Engineering · · 8 min read
Documenting GraphQL APIs: what's different from REST

Take your REST doc template — one page per endpoint, a fixed request shape, a fixed response shape, a table of status codes — and point it at a GraphQL API. It breaks almost immediately. There’s one endpoint, not fifty. Every response is a 200 even when something failed. And the “response shape” isn’t fixed at all — it’s whatever the client asked for in the query. If you try to document GraphQL the way you documented REST, you’ll end up with a reference that’s technically accurate and practically useless.

GraphQL isn’t REST with a different syntax. It inverts who controls the response, it treats mutations and subscriptions as first-class citizens instead of afterthoughts, and it comes with a self-describing schema that changes what “documentation” even means. Here’s what actually needs to change in your approach.

Why your REST doc template breaks for GraphQL

REST documentation is organized around resources and verbs: GET /users/42, POST /orders, DELETE /sessions/{id}. Each one gets its own page, a request example, and a response example that’s basically always accurate, because the server decides the shape of the response and it doesn’t change per-request.

GraphQL has one endpoint (POST /graphql) and the client decides the shape of every response by writing a selection set. That single fact breaks three assumptions your REST template depends on:

  1. There’s no “the response.” A post query can return just a title, or a title plus the author plus the first ten comments plus each commenter’s avatar. Documenting “the response” as a single fixed JSON blob is misleading — it’s one of thousands of valid shapes.
  2. Status codes stop meaning what they used to. A GraphQL request that fails validation, hits a resolver error, or partially succeeds still comes back as HTTP 200, with the failure encoded in an errors array alongside whatever data did resolve. If your docs still have a “4xx/5xx” section modeled on REST, it’s actively wrong.
  3. Versioning disappears as a concept. REST docs often have a /v1/, /v2/ split. GraphQL APIs are designed to evolve in place — fields get added and deprecated, not versioned — so your docs need a deprecation story, not a version switcher.

Here’s the difference in practice:

REST-style doc page bolted onto a GraphQL query

### Get Post

`POST /graphql`

**Response 200:**
{
  "data": {
    "post": {
      "title": "Hello World",
      "author": { "name": "Jane" }
    }
  }
}

This shows one arbitrary shape as if it were the contract. A client that asked for comments instead of author gets a completely different response, and this doc gives them zero signal that was even possible.

Schema-anchored doc page

### `post(id: ID!): Post`

Returns a single post by ID, or `null` if no post matches.

**Fields you can select:** see [`Post` type reference](#post) — includes
`title`, `body`, `author`, `comments(first, after)`, and 6 more.

**Example selection:**
​```graphql
query GetPost($id: ID!) {
  post(id: $id) {
    title
    author { name }
  }
}
​```

The response mirrors whatever fields you select — this is illustrative, not exhaustive.

The second version documents the contract (what fields exist, what they return, how to select them) instead of one snapshot of a response. That’s the mental shift the rest of this post is built on.

Schema-first docs: using introspection to auto-generate reference

The good news is that GraphQL hands you something REST never did: a machine-readable, queryable description of the entire API. Every spec-compliant GraphQL server supports introspection — you can literally query the schema itself:

query IntrospectPostType {
  __type(name: "Post") {
    name
    description
    fields {
      name
      description
      type { name kind ofType { name } }
      isDeprecated
      deprecationReason
    }
  }
}

Run that against any GraphQL endpoint and you get back every field on Post, its type, and — if the schema authors did their job — a human-readable description for each one. That’s the entire reference section of your docs, generated, not hand-written.

This only works if descriptions live in the schema itself. Most teams write these as SDL doc-comments right next to the field they describe:

"""
A blog post authored by a registered user.
"""
type Post {
  id: ID!
  title: String!

  """
  Rendered HTML body. Use `excerpt` for a shorter preview intended
  for list views — fetching `body` on a list query is expensive.
  """
  body: String!

  author: User!

  """
  Paginated comments, newest first. Defaults to the first 10.
  """
  comments(first: Int = 10, after: String): CommentConnection!
}

Put the description where the field is defined, and introspection carries it straight into any doc generator, GraphiQL’s sidebar, or your own reference page. This is the same principle GitDoc is built around for REST and RPC APIs — the source of truth for reference docs should be the schema or the code, not a wiki page someone maintains by hand. For GraphQL it’s even more literal: the schema is the doc source, introspection just extracts it.

Schema-first docs solve the reference layer completely. What they don’t solve is everything introspection can’t express — which is most of what makes an API usable.

Documenting queries, mutations, and subscriptions separately

REST treats every HTTP verb as roughly the same kind of thing to document: request in, response out. GraphQL’s three root types — Query, Mutation, Subscription — have genuinely different contracts, and lumping them into one generic “operations” page loses information developers need.

query GetPost($id: ID!) {
  post(id: $id) {
    title
    author { name }
  }
}

mutation CreateComment($input: CreateCommentInput!) {
  createComment(input: $input) {
    comment { id body createdAt }
    errors { field message }
  }
}

subscription OnCommentAdded($postId: ID!) {
  commentAdded(postId: $postId) {
    id
    body
    author { name }
  }
}

Each of these needs a different documentation checklist:

  • Queries are read-only and (usually) cacheable. Document expected query cost or depth limits, whether results are eventually consistent or strongly consistent, and which arguments are indexed versus expensive to filter on.
  • Mutations have side effects and their own error convention. Since HTTP status won’t tell the client anything, document the errors field on the payload explicitly — what shape it takes, what field-level codes mean, and whether the mutation is safe to retry on timeout (most aren’t idempotent by default).
  • Subscriptions run over a persistent transport (WebSocket or SSE), not request/response. Document connection setup, authentication over that transport (it’s often different from your HTTP auth), keep-alive/ping behavior, what happens on reconnect, and delivery guarantees — at-most-once is far more common than “guaranteed,” and that’s a fact developers need before they build on it, not one they should discover in production.

If your docs describe all three the same way — “here’s an example, here’s a response” — the mutation error contract and the subscription reconnect behavior are exactly the two things that get left out, and they’re the two things support tickets are made of.

The N+1 problem and other GraphQL-specific gotchas worth documenting

GraphQL’s flexibility is also where its sharpest edges live, and none of them show up in a plain schema reference. If your docs don’t call these out explicitly, developers find out about them the hard way — usually in production, at scale.

  1. The N+1 problem. A query for 50 posts, each resolving its own author, can trigger 1 query for the posts plus 50 more for the authors — one per item — if the server isn’t batching. This is invisible in the schema and invisible in a small test query; it only shows up under load. Document whether your server batches resolvers (e.g., via DataLoader) and, if it doesn’t for a given field, warn API consumers that nesting it inside a list is expensive.
  2. Query cost and depth limits. Because clients compose arbitrary nested selections, a single request can fan out into an enormous amount of server work. Document your complexity scoring or depth limit, what happens when a client exceeds it (rejected outright, or throttled), and give an example of a query that’s close to the ceiling.
  3. Partial success is a first-class outcome. A response can contain both data and errors at the same time — some fields resolved, one didn’t. Document what a client should do with that: render what succeeded, surface the specific failed field, and never treat errors as all-or-nothing the way a REST client treats a 500.
  4. Nullability is a real contract, not a suggestion. A field marked String (nullable) versus String! (non-null) changes how a client must handle it — and changing a field from non-null to nullable, or vice versa, is a breaking change even though the field “still exists.” Document nullability changes with the same weight you’d give a removed REST field.
  5. Pagination conventions aren’t obvious from the schema alone. Whether you use Relay-style cursor connections (edges, node, pageInfo) or a simpler offset/limit, document it once, globally, instead of re-explaining it on every paginated field.

None of these live in __type or __schema. They live in prose that a human wrote because they’d been burned by them — which is exactly the content that goes stale fastest, because nobody remembers to update the “gotchas” section when the underlying resolver changes.

Tools that turn a GraphQL schema into browsable docs

For the reference layer, you don’t need to build anything from scratch — the ecosystem has this mostly solved:

  • GraphiQL / Apollo Sandbox — interactive, in-browser explorers that read the live schema via introspection and let developers build and run queries against real data. Great for exploration, not meant to be your canonical docs site.
  • SpectaQL — generates a static, three-pane reference site (à la Stripe’s docs) directly from a schema file, including descriptions and deprecations pulled from SDL comments.
  • graphql-markdown — converts a schema into Markdown files you can drop into an existing docs site (Docusaurus, VitePress, etc.) alongside your hand-written guides.
  • Hasura / PostGraphile consoles — if your schema is auto-generated from a database, these ship a browsable console out of the box, though you’ll still want a real docs site for anything narrative.
  • Postman / Insomnia GraphQL support — useful for letting developers try mutations against a sandbox, less useful as a documentation surface of record.

Every one of these tools is excellent at the same job: turning __schema into a page. None of them write the N+1 warning, the subscription reconnect policy, or the mutation retry semantics — that’s still on you, and it’s the part that breaks first when the resolver changes but nobody remembers to update the paragraph next to it.

That gap — schema reference that regenerates itself, narrative docs that quietly rot — is exactly what we built GitDoc to close. It watches your schema and resolver code, and when a field’s type changes, a mutation’s error shape shifts, or a new subscription gets added, it opens a pending docs update for a human to review instead of leaving it for someone to notice six sprints later. Start free →

Keep reading