Search Tech Journey

Find topics, journeys and posts

6-month learning plan57 / 130
back to blog
backend apisintermediate 55m read

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).

🧠SoftwareM06 · Backend & APIs· Session 057 of 130 90 min

🎯 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.

You will be able to
  • 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

REST is a fixed menu; GraphQL is a build-your-own-plate buffet
🌍 Real world

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.

💻 Code world

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)

Honest tradeoffs
  • 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

  1. 2012
    Facebook builds GraphQL internally
    Mobile team hits the over-fetching wall on News Feed. Lee Byron + Nick Schrock design a query language for it.
  2. 2015
    Open-sourced at React.js Conf
    GraphQL spec + reference JS implementation released. Apollo + Relay ecosystems emerge.
  3. 2016
    GitHub launches API v4 in GraphQL
    First major public API to ship GraphQL alongside REST. Legitimises the approach.
  4. 2019
    GraphQL Federation (Apollo)
    Compose one graph across many microservices. Netflix, Airbnb, and Expedia adopt at scale.
  5. 2023
    GraphQL Fusion / Grafbase / composability standards
    Federation 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

11
Client asks

`{ posts(limit:10) { author { name } } }` — a list of 10 posts and each post's author.

22
Root resolver runs

`SELECT * FROM posts LIMIT 10` — one query, 10 rows.

33
Field resolver fires 10 times

For each post, `SELECT * FROM users WHERE id = post.author_id` — 10 more queries.

4😵
Total: 11 queries

For 10 posts. Scale to 100 posts on a dashboard, that's 101 queries. DB dies.

5
DataLoader fix

Batch all author IDs from a single tick, run one `WHERE id IN (…)`. Total: 2 queries.

GraphQL vs REST: when each wins

Pick GraphQL when…

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
Pick REST when…

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
Pick gRPC when…

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

Schema validation
Every incoming query is parsed and validated against the schema before execution. Free — it's the framework's job.
validate
Query depth + complexity limits
Reject queries deeper than N or with cost > M. Prevents `posts.comments.author.posts.comments…` DoS.
limit
DataLoader batching
Coalesce N+1 patterns into batched IN-queries. Non-negotiable for any list-with-nested-field.
batch
Persisted queries
Client ships a query hash instead of the full text; server executes a pre-registered query. Recovers HTTP caching and blocks arbitrary queries in prod.
persist
Field-level authorisation
Every resolver checks permissions. Central middleware if possible. Field-level leaks are the #1 GraphQL security incident.
auth
Rate limiting by cost
Charge each query by complexity/points, not just requests-per-second. A single expensive query can equal 1000 REST calls.
cost

Common misconception
✗ What most people think

"GraphQL replaces REST and solves over-fetching, so it's strictly better. Clients ask for exactly what they need — there's no downside."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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.

From first principles
Start with the question

Why does GraphQL need query complexity analysis when REST does not? Both accept requests from untrusted clients.

  1. 1
    In 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
  2. 2
    In 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
  3. 3
    If the schema contains any cycle — User → posts → author → posts is 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
  4. 4
    Therefore 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
  5. 5
    So 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

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.

Mental modelA typed graph the client walks

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.
🔔 Fires when you see

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.

The tradeoff

You have many client types with divergent data needs. GraphQL, REST with expansions, or a backend-for-frontend per client?

GraphQL
+ you gain clients evolve without server changes, which removes the backend team from the critical path of every UI change. Strong typing end-to-end, generated client code, and one round trip for arbitrarily complex screens.
− you pay you must build and operate complexity limiting, dataloader batching, per-field observability and a caching strategy from scratch. Debugging is harder because a slow request no longer maps to an endpoint, and per-field authorization is fiddly and easy to get wrong.
pick when when you have many client teams shipping independently and the backend team is measurably the bottleneck for UI changes
REST with expansion parameters
+ you gain keeps HTTP caching, CDN, per-endpoint metrics and existing tooling intact, while addressing the worst of over-fetching. The operational model is unchanged, so nothing new needs learning.
− you pay the expansion syntax grows into an ad-hoc query language nobody designed, and cache hit rate falls as the parameter space widens. You end up rebuilding a fraction of GraphQL with none of the type safety.
pick when when over-fetching is confined to a handful of known endpoints and the client count is small
Backend-for-frontend per client
+ you gain each client gets an API shaped precisely for it, owned by the team that consumes it. Optimisation is targeted, and there is no shared schema to negotiate across teams.
− you pay duplicated logic across BFFs that drifts apart, more services to deploy and monitor, and a new consistency problem when a business rule must change in four places.
pick when when clients differ fundamentally rather than in degree — a public API, an internal admin tool and a mobile app have genuinely different needs
What a senior engineer actually does

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.

"""graphql_demo.py A tiny GraphQL server showcasing schema, resolvers,the N+1 problem, and the DataLoader fix. Run: pip install "strawberry-graphql[fastapi]" uvicorn uvicorn graphql_demo:app --reload Then visit: http://127.0.0.1:8000/graphql GraphiQL IDE POST queries there or via curl: curl -X POST http://127.0.0.1:8000/graphql \\ -H 'Content-Type: application/json' \\ -d '{"query":"{ posts { title author { name } } }"}'"""from __future__ import annotations import asynciofrom collections import defaultdictfrom typing import Optional

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/graphql

Expected CLI output:

naive DB hits: {'users_naive': 5} one per post = N queriesbatched DB hits: {'users_batched': 1, 'users_batched_ids': 3} one query, deduped IDs

That's the whole GraphQL performance story in one script.

Anatomy of the script

What each block teaches

@strawberry.type
Python classes become GraphQL types. Fields with type hints become schema fields. Code-first schemas keep types + resolvers in one place.
types
@strawberry.field on Post.author
A resolver is just a method on the parent type. GraphQL calls it once per parent — and that's where N+1 is born.
resolver
load_user_naive vs load_users_batched
Same data, two shapes. Naive takes one id; batched takes a list. DataLoader coalesces per-tick .load() calls into one .load_fn call.
batch
DataLoader in context
Loaders live per-request (per-context). Never make them global — you'd leak data across requests.
scope
info.context
Anything you inject via context_getter is available in every resolver. Standard place to put loaders, auth, DB pool.
context
QUERY_COUNTS instrumentation
The proof: naive fires N+1 DB hits, batched fires 2. Log this in dev; assert on it in tests.
instrument
Try itAdd a query depth limit and watch it block a malicious query

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.

💡 Hint · Strawberry has a `QueryDepthLimiter` extension. Set max_depth=3 and try a 5-deep query. Real production servers set both depth and complexity limits.

(d) Production reality · 15 min

War story GitHub· 2017v3 REST → v4 GraphQL
🔥 What broke

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.

🧯 The fix
GitHub launched v4 as GraphQL. A single query fetches exactly the fields a screen needs across repos, issues, users, and reviews. Rate limits changed from RPS to query-cost points so complex queries scale fairly. Both v3 REST and v4 GraphQL still ship in 2025.
🎓 Lesson to steal
Dual-stack (REST + GraphQL) is fine and often correct. REST for scripts/CI/CD, GraphQL for UIs. Don't force one API to be everything.
Post-mortem
War story Shopify· 2021Storefront + Admin GraphQL, billions of requests/day
🔥 What broke
Public storefronts running third-party apps could issue arbitrarily complex queries against Shopify's GraphQL API. A misbehaving app dropped one merchant's site by fetching every product with every image variant in a single query.
🧯 The fix
Shopify implemented query cost analysis: every field gets a point value, queries have a per-app budget (points/minute), and expensive queries are throttled or rejected. Cost is calculated statically before execution — expensive queries never run.
🎓 Lesson to steal
Rate-limit GraphQL by cost, not by request count. One query can equal 10,000 REST calls in DB load. Publish the cost formula so integrators can optimise their own usage.
Post-mortem
War story Common failure mode · everywherethe ‘field-level authorization leak’
🔥 What broke
A schema has a `User` type with an `email` field. The 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.
🧯 The fix

Two disciplines that prevent this:

  1. Field-level auth: every sensitive field checks permissions at the resolver, not at the parent. Use middleware or a directive (@auth(role: "admin")).
  2. Deny by default: sensitive fields require an explicit allow-list per role. Reviewers must justify additions in PRs.
🎓 Lesson to steal
In REST, auth lives on the endpoint. In GraphQL, auth lives on every field reachable via any traversal. Miss one and the whole graph leaks.

Where this shows up in the rest of the plan

GraphQL is one option in the API-shape toolbox
S055 · HTTP fundamentals
GraphQL is one big POST endpoint at the wire level.
S056 · REST API design
The alternative and the compare-and-contrast. Most orgs run both.
S058 · gRPC & Protobuf
Third option for internal RPC — schema-first like GraphQL but binary + streaming.
S059 · Auth
Field-level GraphQL auth is the trickiest part; JWT + directive-based auth patterns apply here.
S073 · API gateway
GraphQL federation (Apollo Router, Grafbase) sits at the gateway layer.
S085 · Frontend rendering
React + Apollo/Relay is the client-side counterpart; server-side rendering fetches through the same graph.

(e) Recall + stretch · 10 min

Quick recall · click to reveal
★ = stretch question

Explain-out-loud test

Move on when you can teach these:

  1. Why did Facebook invent GraphQL, and what specific problem did REST fail at?
  2. What is N+1 and how does DataLoader fix it?
  3. 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.