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

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

GraphQL Schema: The Type System

# 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 than email: 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.

Queries, Mutations, Subscriptions

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 errors in the response. GraphQL returns partial data + an errors array; data might 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:

Resolvers + The N+1 Problem + DataLoader

// 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.user resolves the user(id) field β†’ returns a User object.
  • User.posts resolves the posts field on a User β†’ returns [Post].
  • Post.author resolves the author field on a Post β†’ returns a User.

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

GraphQL Server: Apollo, Federation, Production

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 @cacheControl directives.
  • 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

  1. Define a schema β€” type Query { users: [User] } + type User { id, name }.
  2. Write resolvers β€” one function per field, returning data from your DB.
  3. Run Apollo Server β€” startStandaloneServer(new ApolloServer({ typeDefs, resolvers })).
  4. Open the playground β€” Apollo Server ships with a built-in query UI at localhost:4000.
  5. Write a query β€” query { users { id name } } β†’ see the response.
  6. Add a mutation β€” type Mutation { createUser(name: String): User }.
  7. Add variables β€” query GetUser($id: ID!) { user(id: $id) { name } }.
  8. Add DataLoader for any nested list resolver to prevent N+1.
  9. Add auth via context β€” extract token, verify, put user in context.
  10. 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 β†’ author fires N queries. Always batch.
  • Not handling errors array β€” GraphQL returns partial data + errors; data can 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 (.graphql files) is explicit; code-first (generate schema from code) is DRY. Pick one and be consistent.

Further Reading

GraphQL is the API query language alongside REST and gRPC β€” these PyShine tutorials connect to it:


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

Watch PyShine on YouTube

Contents