Learn GraphQL in a Single Post: Complete Tutorial From Schema and Queries to Resolvers and Apollo Federation
GraphQL is a query language for APIs β instead of multiple REST endpoints that each return a fixed shape, you expose one endpoint where the client describes exactly what data it needs, and the server returns exactly that (no over-fetching, no under-fetching, no versioning). Itβs the API style used by GitHub, Shopify, Airbnb, and the Apollo ecosystem. This single post covers the whole stack in five stages, with hand-drawn diagrams and runnable code.
Learning Roadmap
The roadmap moves from the schema (Stage 1), through how clients query (Stage 2), how they write (Stage 3), how the server resolves each field (Stage 4), and the production architecture (Stage 5). The REST tutorial is the prerequisite β GraphQL is the alternative to REST.
Stage 1 β Schema: The Type System
The schema is the contract
A GraphQL schema defines every type, field, and entry point the API exposes. Itβs the contract between client and server β the client can only ask for what the schema declares.
# schema.graphql
type Query {
user(id: ID!): User
posts(limit: Int = 10): [Post!]!
}
type Mutation {
createUser(input: CreateUserInput!): User!
}
type User {
id: ID!
name: String!
email: String
posts: [Post!]!
}
type Post {
id: ID!
title: String!
author: User!
}
input CreateUserInput {
name: String!
email: String!
}
scalar DateTime # custom scalar
enum Status { ACTIVE INACTIVE BANNED }
Key concepts
| Concept | What it means |
|---|---|
type Query | the root read entry point β every read operation starts here |
type Mutation | the root write entry point β every write starts here |
| Object type | a named type with fields (e.g. User, Post) |
| Field | a property of a type with a return type + optional arguments |
! (non-null) | the field always returns a value (never null) |
[T!]! | a non-null list of non-null items |
| Scalar | Int, Float, String, Boolean, ID, or a custom scalar |
| Enum | a fixed set of values |
| Input type | a type used as a mutation argument (canβt be used as a return type) |
| Interface / Union | polymorphic types (a field can return one of several types) |
Pitfall:
!(non-null) is a commitment β once you declare a field non-null, it must always return non-null, or the entire query errors. Be conservative: only mark non-null when you can guarantee it.email: String(nullable) is safer thanemail: String!if some users might not have an email.
Stage 2 β Queries: Selection Sets, Variables, Fragments
A query asks for exactly the fields it wants
query {
user(id: "1") {
name
posts {
title
}
}
}
// response
{
"data": {
"user": {
"name": "Ada",
"posts": [
{ "title": "Hello World" },
{ "title": "GraphQL Tips" }
]
}
}
}
The client asks for name and posts.title β and gets exactly those fields. If a new field is added to the User type, existing clients donβt break (they donβt ask for it). This is GraphQLβs core advantage over REST: the client shapes the response.
Variables β parameterize the query
query GetUser($id: ID!) {
user(id: $id) {
name
}
}
{ "id": "1" } // variables JSON
Variables separate the query text from the arguments β the query can be cached (same text every time), and the variables change. This is the foundation of persisted queries (Stage 5).
Fragments β reusable selection sets
fragment UserCard on User {
id
name
email
}
query {
user(id: "1") {
...UserCard
posts { title }
}
}
Fragments let you define a set of fields once and spread them (...FragmentName) into any query β the same selection used in multiple places without duplication.
Aliases β fetch the same field with different arguments
query {
alice: user(id: "1") { name }
bob: user(id: "2") { name }
}
{ "data": { "alice": { "name": "Ada" }, "bob": { "name": "Bob" } } }
Aliases rename the field in the response β so you can call the same field with different arguments in one query.
Subscriptions β real-time over WebSocket
subscription {
newPost {
id
title
}
}
A subscription is a long-lived query over a WebSocket β the server pushes updates when a new post is created. The client receives a stream of results.
Stage 3 β Mutations: Write Data
The Mutation root type
type Mutation {
createUser(input: CreateUserInput!): User!
updatePost(id: ID!, title: String!): Post!
deleteUser(id: ID!): Boolean!
}
mutation {
createUser(input: { name: "Ada", email: "ada@example.com" }) {
id
name
}
}
{ "data": { "createUser": { "id": "42", "name": "Ada" } } }
A mutation writes data and returns the result β the client selects the fields it wants from the returned object, just like a query. The response shape is client-controlled.
Serial execution
Unlike queries (where the resolver for each field can run in parallel), mutations in a single request execute serially β the first mutation completes before the second starts. This prevents race conditions when a mutation has side effects that the next mutation depends on.
Pitfall: A mutation that returns a type doesnβt guarantee the write succeeded β you must check for
errorsin the response. GraphQL returns partial data + anerrorsarray;datamight be null for the failed field while other fields succeed.
Stage 4 β Resolvers: Per-Field Functions + The N+1 Problem
What a resolver is
A resolver is a function that fetches the data for one field. The server walks the query tree, calling the resolver for each field:
// Apollo Server resolvers
const resolvers = {
Query: {
user: (parent, args, context) => db.user.findById(args.id),
},
User: {
posts: (user) => db.post.findAll({ where: { userId: user.id } }),
},
Post: {
author: (post) => db.user.findById(post.userId),
},
};
Query.userresolves theuser(id)field β returns aUserobject.User.postsresolves thepostsfield on aUserβ returns[Post].Post.authorresolves theauthorfield on aPostβ returns aUser.
Each resolver gets (parent, args, context, info) β the parent object (from the previous resolver), the field arguments, the request context (DB, auth), and execution info.
The N+1 problem
The Post.author resolver runs once per post. If a user has 100 posts, thatβs 100 separate db.user.findById(post.userId) calls β 1 query to get the userβs posts, then N=100 queries to get each postβs author. This is the classic N+1 problem.
DataLoader β batch + cache
DataLoader solves N+1 by batching: it collects all the author(id) calls within one request tick, then fires a single WHERE id IN (...) query:
const DataLoader = require("dataloader");
const userLoader = new DataLoader(async (userIds) => {
const users = await db.user.findAll({ where: { id: { [Op.in]: userIds } } });
// return in the same order as userIds
return userIds.map(id => users.find(u => u.id === id));
});
// in the resolver:
Post: {
author: (post) => context.userLoader.load(post.userId),
}
load(id) returns a Promise; DataLoader collects all calls in the same tick, deduplicates (same ID called twice β 1 DB query), and resolves them all from one batched query. N+1 becomes 2 (1 for posts, 1 batched for authors).
Pitfall: If you donβt use DataLoader, nested list queries (users β posts β author) can generate hundreds of DB queries. Itβs the #1 GraphQL performance problem. Always batch nested resolvers with DataLoader or a similar pattern.
Stage 5 β Production: Apollo, Federation, Caching
Apollo Server
Apollo Server is the reference implementation for Node.js:
const { ApolloServer } = require("@apollo/server");
const { startStandaloneServer } = require("@apollo/server/standalone");
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, { listen: { port: 4000 } });
console.log(`Server ready at ${url}`);
Alternatives: graphql-yoga (modern Node), async-graphql (Rust), Graphene (Python), gqlgen (Go).
Federation β microservices
Apollo Federation splits a monolithic schema across subgraphs (each service owns part of the types), composed by a gateway:
User Service: type User { id, name, posts: [Post] } (owns User)
Post Service: type Post { id, title, author: User } (owns Post, extends User with author)
Gateway: one endpoint, merges the subgraphs into a supergraph
The client queries the gateway as if itβs one schema; the gateway routes each field to the owning subgraph. This is how you scale GraphQL across microservices without a monolith.
Persisted queries
Instead of sending the full query text on every request (which can be large), the client sends a hash of the query. The server looks up the hash β the stored query β executes it. Benefits: smaller payloads, query allow-listing (the server only accepts known queries), and CDN caching by URL hash.
Caching
- Field-level: Apollo Server can cache individual fields with
@cacheControldirectives. - CDN/HTTP: For queries that donβt use
context(no auth, no per-user data), the response is cacheable by URL (like REST). - Client-side: Apollo Client caches query results in memory; subsequent queries for the same data resolve from cache.
Complexity limits
A malicious client can send a deeply nested query that causes exponential resolver calls. Complexity analysis assigns a cost to each field and rejects queries that exceed a budget:
const cost = require("graphql-cost-analysis");
// max complexity: 1000; deep nested query -> rejected with 429
Auth
Auth is handled via context β the server extracts the token from headers, validates it, and puts the user in context:
const server = new ApolloServer({
typeDefs, resolvers,
context: async ({ req }) => {
const token = req.headers.authorization;
const user = await verifyToken(token);
return { user, db }; // available in every resolver as context
},
});
// in a resolver:
Query: {
me: (parent, args, context) => {
if (!context.user) throw new Error("unauthorized");
return context.user;
},
}
Quick-Start Checklist
- Define a schema β
type Query { users: [User] }+type User { id, name }. - Write resolvers β one function per field, returning data from your DB.
- Run Apollo Server β
startStandaloneServer(new ApolloServer({ typeDefs, resolvers })). - Open the playground β Apollo Server ships with a built-in query UI at
localhost:4000. - Write a query β
query { users { id name } }β see the response. - Add a mutation β
type Mutation { createUser(name: String): User }. - Add variables β
query GetUser($id: ID!) { user(id: $id) { name } }. - Add DataLoader for any nested list resolver to prevent N+1.
- Add auth via context β extract token, verify, put user in context.
- Set complexity limits β prevent abusive deeply-nested queries.
Common Pitfalls
!on everything β non-null fields that return null break the whole query. Be conservative.- N+1 in nested resolvers β without DataLoader,
posts β authorfires N queries. Always batch. - Not handling
errorsarray β GraphQL returns partial data + errors;datacan be null for the failed field while other fields succeed. - Mutations run serially β they donβt parallelize like queries; be aware of ordering.
- No complexity limit β a malicious nested query can cause exponential resolver calls. Always set a max complexity.
- Caching auth-gated data at CDN β if a query reads
context.user, the response is per-user and must not be CDN-cached. - Over-federation β federation adds latency (gateway β subgraph); donβt split into 20 subgraphs for a small team.
- Schema-first vs code-first β schema-first (
.graphqlfiles) is explicit; code-first (generate schema from code) is DRY. Pick one and be consistent.
Further Reading
- GraphQL Spec β the official specification
- Apollo Docs β server, client, federation
- GraphQL.org Learn β the official tutorial
- How to GraphQL β full-stack tutorial
- DataLoader β the batching library
Related guides
GraphQL is the API query language alongside REST and gRPC β these PyShine tutorials connect to it:
- Learn REST API in One Post β the comparison; REST vs GraphQL tradeoffs.
- Learn gRPC + Protobuf in One Post β the other API style; protobuf vs GraphQL schema.
- Learn Node.js + Express in One Post β Apollo Server runs on Node; know the runtime.
- Learn PostgreSQL in One Post β what resolvers query; DataLoader batches into IN-clauses.
- Learn React + Next.js in One Post β Apollo Client (the frontend cache) pairs with React.
GraphQLβs value is that the client shapes the response β one endpoint, one query, exactly the fields you asked for, no over-fetch, no under-fetch, and the schema is the contract that makes it type-safe. The five stages here β schema, queries, mutations, resolvers, production β cover everything from a Enjoyed this post? Never miss out on future posts by following us type Query to a federated, DataLoader-batched, persisted-query, complexity-limited production gateway. The two habits that pay off: use DataLoader for every nested list resolver (N+1 is the #1 performance killer), and be conservative with ! (a non-null field that returns null breaks the whole query). Define a schema, write resolvers, open the Apollo playground, and run query { users { name } } β once you see the client-controlled response shape, the model clicks.