S057 · GraphQL — Schema, Resolvers, N+1, When to Pick It
GraphQL isn't ‘REST but better’ — it's a different set of tradeoffs. Schema-first design, resolver mechanics, the N+1 disaster and DataLoader fix, and a defensible checklist for when GraphQL beats REST (and when it emphatically does not).
🎯 Build a small GraphQL server with a schema + resolvers, reproduce the N+1 query problem, fix it with DataLoader, and explain when you would (and would not) pick GraphQL over REST.
Why this session exists
GraphQL is neither a silver bullet nor a gimmick — it's a query language born from Facebook's mobile-scale over-fetching problem. Understanding why it exists, where it shines (rich UI graphs, mobile bandwidth, aggregating microservices), and where it hurts (public APIs, cacheability, unbounded query cost) will save you from both cargo-cult adoption and reflexive rejection. This session shows the machinery end-to-end so you can make the call yourself.
- Read a GraphQL schema (SDL) and explain types, queries, mutations, and resolvers.
- Build a small Strawberry/Python GraphQL server backed by a real data source.
- Reproduce the N+1 problem by naive resolvers, then fix it with DataLoader batching.
- List three cases where GraphQL is the right choice and three where REST wins.
- Explain persisted queries + query depth/cost limiting as required production controls.
Prerequisites
- S055 · HTTP fundamentals — GraphQL sits on top of HTTP POST.
- S056 · REST API design — the alternative you'll compare against.
- S037 · SQL joins & N+1 (or your Django/ORM experience) — you've seen N+1 before.
(a) Intuition · 5 min
A fixed menu lists dishes. If you order the ‘classic breakfast’ you get eggs, bacon, toast, coffee — even if you only wanted eggs. The kitchen is predictable, the plates are consistent, and the cashier can pre-print receipts.
At a buffet, you fill your plate with exactly what you want. Amazing flexibility, but the kitchen has to prep everything upfront, portion control gets weird, and cost is harder to predict.
REST returns fixed resource shapes. A mobile client fetches /users/42 and receives 30 fields even if it needed 3 — that's over-fetching, and on a 3G network in 2015 it was a real product problem.
GraphQL lets the client declare exactly which fields it wants: query { user(id:42) { name, avatar } }. The server responds with only those. Rich UIs stop making 5 round trips; mobile clients ship on time.
What GraphQL actually gives you (and doesn't)
- No over-fetching — clients specify fields; response is exactly that shape.
- One round trip — a single query can traverse `user → posts → comments → author` in one request.
- Strongly typed schema — introspection generates client types, docs, tooling for free.
- Poor HTTP caching — every request is a POST with a unique body, so CDNs can't help without persisted queries.
- Complex authorisation — resolvers are the enforcement point, easy to leak fields via a related type.
- Unbounded query cost — a naive `posts.comments.author.posts.comments` query can DoS your DB.
The (short) history
- 2012Facebook builds GraphQL internallyMobile team hits the over-fetching wall on News Feed. Lee Byron + Nick Schrock design a query language for it.
- 2015Open-sourced at React.js ConfGraphQL spec + reference JS implementation released. Apollo + Relay ecosystems emerge.
- 2016GitHub launches API v4 in GraphQLFirst major public API to ship GraphQL alongside REST. Legitimises the approach.
- 2019GraphQL Federation (Apollo)Compose one graph across many microservices. Netflix, Airbnb, and Expedia adopt at scale.
- 2023GraphQL Fusion / Grafbase / composability standardsFederation matures; the ‘one graph per company’ pattern becomes an architecture, not an experiment.
(b) Visual walkthrough · 15 min
Request lifecycle
Every field the client asked for is filled by a resolver. Resolvers can hit databases, REST services, caches — anything. This is why GraphQL is described as a facade: the schema is one graph, the data can come from anywhere.
The schema is the contract
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
body: String!
author: User!
comments: [Comment!]!
}
type Comment {
id: ID!
body: String!
author: User!
}
type Query {
user(id: ID!): User
posts(limit: Int = 10): [Post!]!
}
type Mutation {
createPost(authorId: ID!, title: String!, body: String!): Post!
}Reading it: ! means non-null. [X!]! means "a non-null list of non-null X". Query and Mutation are the two entry points; everything else is reachable through them.
N+1: the mistake everyone makes once
`{ posts(limit:10) { author { name } } }` — a list of 10 posts and each post's author.
`SELECT * FROM posts LIMIT 10` — one query, 10 rows.
For each post, `SELECT * FROM users WHERE id = post.author_id` — 10 more queries.
For 10 posts. Scale to 100 posts on a dashboard, that's 101 queries. DB dies.
Batch all author IDs from a single tick, run one `WHERE id IN (…)`. Total: 2 queries.
GraphQL vs REST: when each wins
Client-shaped data
- Multiple UIs (web, iOS, Android) with different field needs
- Deep nested graph traversals in one screen (user → posts → comments)
- You control both client and server (internal APIs, first-party mobile)
- You have infra for DataLoader, query complexity limits, persisted queries
Predictable resource CRUD
- Public API used by strangers (curl, CI scripts, third-party integrations)
- Heavy read caching (CDN, HTTP cache) is critical
- Simple CRUD on well-defined resources
- You don't have engineers to run a GraphQL gateway safely
Internal service-to-service
- Polyglot microservices with strict schemas
- Streaming or bidirectional RPC needed
- Latency-critical internal calls
- You can enforce protobuf discipline org-wide
The production controls you must add
A GraphQL server without these is a footgun
"GraphQL replaces REST and solves over-fetching, so it's strictly better. Clients ask for exactly what they need — there's no downside."
GraphQL moves cost rather than removing it. Over-fetching on the wire is solved; in exchange you inherit an unbounded query surface, HTTP caching that no longer works out of the box (one POST endpoint, one URL), an N+1 problem that reappears inside your resolvers, and query cost that is now determined by the client rather than by you.
The myth is sticky because the demo is genuinely compelling and the first problem it solves — a mobile screen making seven REST calls — is real and painful. What the demo omits is that every GraphQL production deployment eventually acquires a dataloader layer, a query depth limiter, a complexity budget, persisted queries, and its own caching strategy. Those aren't accidents; they are the costs that were moved, arriving one incident at a time.
Write the naive resolver and watch the N+1 appear on the server side:
query { orders(first: 50) { id customer { name } } }With a straightforward per-field resolver this issues 1 query for orders plus 50 queries for customers. The client sent one request and paid for one round trip; your database served 51 queries. Now nest one level deeper and the multiplication continues. Turn on statement logging and count — the number will be larger than you expect.
Why does GraphQL need query complexity analysis when REST does not? Both accept requests from untrusted clients.
- 1In REST, the server author defines every endpoint, so the maximum cost of any request is known at design time and can be measured before deploy.forced by · the set of possible requests is finite and enumerable — it is exactly the set of routes you wrote
- 2In GraphQL the client composes the query from a schema graph, so the set of valid queries is the set of paths through that graph.forced by · composability is the feature; restricting it to a fixed list would just be REST with extra syntax
- 3If the schema contains any cycle —
User → posts → author → postsis a cycle — the number of valid queries is infinite and their cost is unbounded.forced by · a client can nest a cycle arbitrarily deep, and each level multiplies the number of resolver invocations - 4Therefore a single well-formed, schema-valid query can consume arbitrary server resources, and nothing in the type system prevents it.forced by · validity in GraphQL means "conforms to the schema", which says nothing about cost
- 5So the server must evaluate cost before execution and reject queries over a budget — depth limits, node limits, or a weighted complexity score computed from the parsed AST.forced by · the only place cost is knowable in advance is the parsed query, since execution is where the damage happens
Therefore complexity analysis is not a hardening extra; it is the mandatory replacement for the safety that fixed endpoints gave you for free. A public GraphQL API without it has an unauthenticated denial-of-service in its design, not in its implementation.
And note what this predicts: persisted queries — where clients register queries at build time and send only a hash — recover REST's exact safety property, because the set of executable queries becomes finite and known in advance again. That is why large public GraphQL deployments converge on them. Having derived it, you can see persisted queries are not an optimisation; they are the structural fix.
The schema is a graph of types with edges between them. A query is a path the client traces through that graph, and the response mirrors the query's shape exactly. There is one endpoint because the URL no longer identifies the resource — the query body does.
Each field is backed by a resolver function. Execution is a tree walk: resolve a field, then resolve its children, breadth-first per level. Everything about GraphQL's performance behaviour follows from that walk.
- Resolvers execute per field per parent object. This is why N+1 is the default behaviour rather than an edge case, and why batching (DataLoader) — collecting all IDs needed at one level and issuing a single query — is required infrastructure, not an optimisation.
- The schema is the contract and it is introspectable, so clients generate types from it. This makes breaking changes immediately visible in client builds, which is a genuine advantage over REST's prose documentation.
- Versioning is replaced by deprecation: add fields freely, mark old ones
@deprecated, and use field-level usage analytics to know when nobody queries them any more. This only works if you actually collect per-field usage — without it, you can never remove anything. - HTTP caching does not apply. One URL, POST bodies, response shape varying per query: every layer of intermediate caching you had is gone, replaced by application-level normalised caching in the client (Apollo, Relay) and per-resolver caching on the server.
Fire this model when you see: a database query count that scales with response size · a mobile screen making seven sequential API calls · a single slow query taking down a service · a CDN that is now useless in front of an API · nobody able to say who still uses a field.
You have many client types with divergent data needs. GraphQL, REST with expansions, or a backend-for-frontend per client?
GraphQL earns its complexity when the number of independent client teams is high and their needs change faster than the backend can ship endpoints. Below that threshold it is overhead you will pay for in incidents.
The decisive question is organisational, not technical: is the backend team a bottleneck for client-side changes? If yes, GraphQL removes a coordination cost that dwarfs its operational cost. If no — one app, one backend team, shared standup — it adds machinery to solve a problem you do not have. And if you do adopt it, budget for complexity limits and dataloader on day one; they are not phase two.
(c) Hands-on · 25 min
We'll build a small GraphQL server with Strawberry (Python), reproduce N+1, and fix it with DataLoader. Everything in one file.
Run:
pip install "strawberry-graphql[fastapi]" uvicorn
python graphql_demo.py # CLI mode — see the counts side-by-side
uvicorn graphql_demo:app --reload # server mode — open http://127.0.0.1:8000/graphqlExpected CLI output:
That's the whole GraphQL performance story in one script.
Anatomy of the script
What each block teaches
Add a depth limiter:
from strawberry.extensions import QueryDepthLimiter
schema = strawberry.Schema(
query=Query,
extensions=[QueryDepthLimiter(max_depth=3)],
)Now try executing a deep query in GraphiQL:
{
posts {
author {
# depth 3 — allowed
email
}
}
}vs a deeper one — imagine posts.author.posts.author.posts in a real schema with cyclic references. Depth-limited queries get rejected with a clear error before hitting a single resolver, protecting your DB from adversarial clients.
(d) Production reality · 15 min
GitHub's REST v3 API required fetching repo, then issues, then each issue's assignees, then each assignee's user record — 4+ round trips for one screen on the mobile app. Rate limits kicked in during heavy usage; syncing a repo took minutes.
Public integrators built increasingly cursed workarounds: parallel requests, pre-fetching, custom caches. GitHub's own React apps hit the same pain.
user(id) resolver checks that the requester is authorised to view that user. But another type includes `User` as a nested field (e.g. `Post.author: User`). The author resolver returns any user without permission checks — anyone can list posts and scrape emails.Two disciplines that prevent this:
- Field-level auth: every sensitive field checks permissions at the resolver, not at the parent. Use middleware or a directive (
@auth(role: "admin")). - Deny by default: sensitive fields require an explicit allow-list per role. Reviewers must justify additions in PRs.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
Move on when you can teach these:
- Why did Facebook invent GraphQL, and what specific problem did REST fail at?
- What is N+1 and how does DataLoader fix it?
- Give one screen where GraphQL clearly wins and one where REST clearly wins.
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.