env.dev

GraphQL: The Complete Developer Guide to Queries, Schemas, and APIs

Master GraphQL from scratch: schema design with SDL, queries, mutations, subscriptions, resolvers, the N+1 problem with DataLoader, cursor-based pagination, error handling, security hardening, and the Apollo/Relay ecosystem.

Last updated:

GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data. Created by Facebook in 2012 and open-sourced in 2015, GraphQL lets clients request exactly the data they need — nothing more, nothing less. In a single request you can fetch data from multiple resources that would require 3-5 REST calls. Apps using GraphQL see up to 66% better network performance compared to equivalent REST implementations, and enterprise adoption has grown over 340% since 2023. The latest specification is the September 2025 edition, maintained by the GraphQL Foundation under the Linux Foundation.

What Is GraphQL?

GraphQL is a strongly-typed API layer that sits between your clients and your data sources. Unlike REST, where the server defines fixed response shapes, GraphQL lets the client describe what data it needs using a declarative query language. The server then returns a JSON response matching exactly that shape.

GraphQL request/response flow
Client                                   Server
  │                                          │
  │  POST /graphql                           │
  │  { query: "{ user(id: 1) { name } }" }  │
  │ ──────────────────────────────────────►   │
  │                                          │  ← resolves user(id: 1)
  │  { "data": { "user": { "name": "Ada" }}}│
  │  ◄──────────────────────────────────────  │
  │                                          │
  ✓ Client gets exactly what it asked for

Every GraphQL API exposes a single endpoint (typically /graphql). Clients send queries via HTTP POST and receive JSON responses. The server's schema defines the complete set of available types and operations, and the server validates every query against it before execution.

How Does GraphQL Compare to REST?

AspectRESTGraphQL
EndpointsMultiple (one per resource)Single /graphql endpoint
Data fetchingFixed response shape, over/under-fetchingClient specifies exact fields
VersioningURL versioning (/v1/, /v2/)Schema evolution, no versioning needed
Type systemOptional (OpenAPI/Swagger)Built-in, required
Real-timeRequires separate WebSocket setupSubscriptions built into spec
CachingHTTP caching works nativelyRequires client-side cache (e.g. Apollo)
ToolingPostman, curl, Swagger UIGraphiQL, Apollo Studio, Playground
Learning curveLower — uses familiar HTTP verbsHigher — new query language + schema design

When to use GraphQL: mobile apps with bandwidth constraints, dashboards aggregating multiple data sources, rapidly evolving frontends, and microservice architectures needing a unified API gateway. When to stick with REST: simple CRUD APIs, file uploads/downloads, public APIs where HTTP caching is critical, and teams already invested in OpenAPI tooling.

How Does the GraphQL Type System Work?

Every GraphQL API is defined by a schema written in the Schema Definition Language (SDL). The schema is a contract between client and server — it defines every type, field, and relationship available in the API.

Schema Definition Language (SDL)
# Scalar types: Int, Float, String, Boolean, ID (built-in)
# Custom scalars
scalar DateTime

# Enums — restricted to specific values
enum Role {
  ADMIN
  USER
  MODERATOR
}

# Object types — the core building block
type User {
  id: ID!              # ! means non-nullable
  name: String!
  email: String!
  role: Role!
  posts: [Post!]!      # non-nullable list of non-nullable Posts
  createdAt: DateTime!
}

type Post {
  id: ID!
  title: String!
  body: String!
  author: User!
  tags: [String!]!
  published: Boolean!
}

# Input types — used for mutations
input CreatePostInput {
  title: String!
  body: String!
  tags: [String!]
}

# Interface — abstract type with shared fields
interface Node {
  id: ID!
}

# Union — one of several types
union SearchResult = User | Post

Type Modifiers at a Glance

SyntaxNullable?Example value
StringField can be nullnull or "hello"
String!Field is never null"hello"
[String]List and items can be nullnull, [], ["a", null]
[String!]Items never null, list can be nullnull, [], ["a", "b"]
[String!]!Nothing is null[], ["a", "b"]

How Do You Write GraphQL Queries?

Queries are how you read data. They mirror the shape of the response — what you write is what you get.

Basic query with nested fields
query GetUser {
  user(id: "42") {
    name
    email
    posts {
      title
      published
    }
  }
}
Response
{
  "data": {
    "user": {
      "name": "Ada Lovelace",
      "email": "ada@example.com",
      "posts": [
        { "title": "First Program", "published": true },
        { "title": "Draft Ideas", "published": false }
      ]
    }
  }
}

Variables, Aliases, and Fragments

Real-world queries use variables for dynamic values, aliases to rename fields, and fragments to reuse field selections.

Variables, aliases, and fragments
# Fragment — reusable field selection
fragment UserBasic on User {
  id
  name
  email
}

# Query with variables and aliases
query GetUsers($firstId: ID!, $secondId: ID!) {
  alice: user(id: $firstId) {
    ...UserBasic
    role
  }
  bob: user(id: $secondId) {
    ...UserBasic
    posts {
      title
    }
  }
}

# Variables (sent as JSON alongside the query)
# { "firstId": "1", "secondId": "2" }

Directives

Directives modify query execution. Two are built-in: @include(if: Boolean!) and @skip(if: Boolean!).

Conditional fields with directives
query GetUser($id: ID!, $withPosts: Boolean!) {
  user(id: $id) {
    name
    email
    posts @include(if: $withPosts) {
      title
    }
  }
}

How Do Mutations Work?

Mutations are for writing data — creating, updating, and deleting. Unlike queries which run in parallel, mutation fields execute sequentially (left to right, top to bottom) to avoid race conditions. Best practice: use a single input argument and return the mutated object so the client can update its cache.

Mutation with input type
mutation CreatePost($input: CreatePostInput!) {
  createPost(input: $input) {
    id
    title
    body
    author {
      name
    }
  }
}

# Variables
# {
#   "input": {
#     "title": "GraphQL in Production",
#     "body": "Here's what we learned...",
#     "tags": ["graphql", "api"]
#   }
# }
Update and delete mutations
mutation UpdatePost($id: ID!, $input: UpdatePostInput!) {
  updatePost(id: $id, input: $input) {
    id
    title
    body
  }
}

mutation DeletePost($id: ID!) {
  deletePost(id: $id) {
    id    # return the deleted ID for cache eviction
  }
}

What Are GraphQL Subscriptions?

Subscriptions provide real-time, push-based updates from server to client over a persistent connection (typically WebSocket). The client subscribes to an event, and the server pushes data whenever that event occurs.

Subscription example
subscription OnPostCreated {
  postCreated {
    id
    title
    author {
      name
    }
  }
}

# The server pushes a message each time a new post is created:
# { "data": { "postCreated": { "id": "99", "title": "New post!", "author": { "name": "Ada" } } } }

Queries

Read data on demand. Executed in parallel. The most common operation type.

query { }

Mutations

Write data (create, update, delete). Executed sequentially to prevent race conditions.

mutation { }

Subscriptions

Real-time push via WebSocket. Server sends data when events occur.

subscription { }

How Do You Build a GraphQL Server?

A GraphQL server needs a schema (types + operations) and resolvers (functions that fetch the actual data). Here is a complete Node.js example using Apollo Server — the most popular GraphQL server library with over 13 million weekly npm downloads.

Apollo Server — complete example (server.ts)
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';

// 1. Define your schema
const typeDefs = `#graphql
  type User {
    id: ID!
    name: String!
    email: String!
    posts: [Post!]!
  }

  type Post {
    id: ID!
    title: String!
    body: String!
    author: User!
  }

  type Query {
    users: [User!]!
    user(id: ID!): User
    post(id: ID!): Post
  }

  input CreatePostInput {
    title: String!
    body: String!
    authorId: ID!
  }

  type Mutation {
    createPost(input: CreatePostInput!): Post!
  }
`;

// 2. Define resolvers — one function per field
const resolvers = {
  Query: {
    users: () => db.users.findAll(),
    user: (_, { id }) => db.users.findById(id),
    post: (_, { id }) => db.posts.findById(id),
  },
  Mutation: {
    createPost: (_, { input }) => db.posts.create(input),
  },
  // Nested resolvers — called when a field is requested
  User: {
    posts: (user) => db.posts.findByAuthorId(user.id),
  },
  Post: {
    author: (post) => db.users.findById(post.authorId),
  },
};

// 3. Create and start the server
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
});
console.log(`GraphQL server ready at ${url}`);

Resolver Execution

Every resolver receives four arguments. Understanding them is key to building effective GraphQL APIs.

ArgumentNamePurpose
1stparentThe result returned by the parent resolver (root resolvers receive undefined)
2ndargsThe arguments passed to this field (e.g. { id: "42" })
3rdcontextShared per-request state — auth info, database connections, dataloaders
4thinfoAST and schema metadata about the query — rarely used directly

How Do You Solve the N+1 Problem with DataLoader?

The N+1 problem is GraphQL's most common performance pitfall. If you query 50 posts and each post resolves its author, that's 1 query for posts + 50 queries for authors = 51 database calls. DataLoader solves this by batching and caching within a single request.

DataLoader batching example
import DataLoader from 'dataloader';

// Create a loader that batches user lookups
// Instead of 50 individual queries, DataLoader makes ONE:
//   SELECT * FROM users WHERE id IN (1, 2, 3, ..., 50)
const userLoader = new DataLoader(async (userIds: readonly string[]) => {
  const users = await db.users.findByIds([...userIds]);
  // IMPORTANT: return results in the same order as the input IDs
  return userIds.map(id => users.find(u => u.id === id));
});

// Use in resolvers
const resolvers = {
  Post: {
    // Without DataLoader: N+1 queries
    // author: (post) => db.users.findById(post.authorId),

    // With DataLoader: batched into 1 query
    author: (post) => userLoader.load(post.authorId),
  },
};
N+1 → batched: query reduction
Without DataLoader (N+1):        With DataLoader (batched):
─────────────────────────        ──────────────────────────
SELECT * FROM posts;             SELECT * FROM posts;
SELECT * FROM users WHERE id=1;  SELECT * FROM users
SELECT * FROM users WHERE id=2;    WHERE id IN (1,2,3,...,50);
SELECT * FROM users WHERE id=3;
...                              Total: 2 queries ✓
SELECT * FROM users WHERE id=50;
Total: 51 queries ✗

How Do You Query GraphQL from the Client?

You can query any GraphQL API with a plain fetch call. For production apps, a dedicated client like Apollo Client or urql adds caching, optimistic updates, and automatic re-rendering.

Plain fetch — works everywhere
const response = await fetch('https://api.example.com/graphql', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    query: `
      query GetUser($id: ID!) {
        user(id: $id) { name email }
      }
    `,
    variables: { id: '42' },
  }),
});

const { data, errors } = await response.json();
Apollo Client — React example
import { useQuery, gql } from '@apollo/client';

const GET_USER = gql`
  query GetUser($id: ID!) {
    user(id: $id) {
      name
      email
      posts { title }
    }
  }
`;

function UserProfile({ userId }: { userId: string }) {
  const { data, loading, error } = useQuery(GET_USER, {
    variables: { id: userId },
  });

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <div>
      <h1>{data.user.name}</h1>
      <p>{data.user.email}</p>
      <ul>
        {data.user.posts.map(post => (
          <li key={post.title}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

What Are GraphQL Best Practices?

Use Input Types for Mutations

Wrap mutation arguments in a single input object type. Keeps the API consistent and simplifies client code.

Return Mutated Objects

Always return the created/updated object from mutations so clients can update their cache without an extra query.

Cursor-Based Pagination

Use connections pattern (edges/nodes/pageInfo) instead of offset pagination for stable, performant pagination.

Schema-First Design

Design your schema before writing resolvers. The schema is your API contract — treat it like a public interface.

Deprecate, Don't Remove

Use @deprecated directive to phase out fields. GraphQL APIs are versionless — evolve the schema instead.

Batch with DataLoader

Always use DataLoader (or equivalent) for nested resolvers to prevent N+1 queries. Create loaders per-request.

Limit Query Depth

Set max depth (typically 7-10) and complexity limits to prevent abuse from deeply nested or expensive queries.

Persisted Queries

In production, send a query hash instead of the full query string. Reduces bandwidth and prevents arbitrary queries.

How Do You Paginate in GraphQL?

The Relay Connection specification is the standard pattern for cursor-based pagination. It provides stable pagination that works with real-time data and avoids the skipped/ duplicated items problem of offset pagination.

Connection-based pagination schema
type Query {
  posts(first: Int, after: String, last: Int, before: String): PostConnection!
}

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type PostEdge {
  cursor: String!
  node: Post!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}
Paginated query
query GetPosts($cursor: String) {
  posts(first: 10, after: $cursor) {
    edges {
      node {
        id
        title
        author { name }
      }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor       # pass this as $cursor for the next page
    }
    totalCount
  }
}

How Do You Handle Errors in GraphQL?

GraphQL always returns HTTP 200 — even when things go wrong. Errors are reported inside the response body. There are two approaches to error handling: the built-in errors array and union-based result types.

Built-in errors array
{
  "data": { "user": null },
  "errors": [
    {
      "message": "User not found",
      "locations": [{ "line": 2, "column": 3 }],
      "path": ["user"],
      "extensions": {
        "code": "NOT_FOUND",
        "http": { "status": 404 }
      }
    }
  ]
}
Union-based result types (recommended for business errors)
union CreatePostResult = Post | ValidationError | AuthError

type ValidationError {
  field: String!
  message: String!
}

type AuthError {
  message: String!
}

type Mutation {
  createPost(input: CreatePostInput!): CreatePostResult!
}

# Client query — handle each case explicitly
mutation CreatePost($input: CreatePostInput!) {
  createPost(input: $input) {
    ... on Post {
      id
      title
    }
    ... on ValidationError {
      field
      message
    }
    ... on AuthError {
      message
    }
  }
}

What Is Introspection?

GraphQL APIs are self-documenting. Any client can query the schema itself using introspection queries. This powers tools like GraphiQL, Apollo Studio, and code generators.

Introspection query
# List all types in the schema
{
  __schema {
    types {
      name
      kind
      description
    }
  }
}

# Get fields for a specific type
{
  __type(name: "User") {
    name
    fields {
      name
      type {
        name
        kind
      }
    }
  }
}

Security note: disable introspection in production. It exposes your entire API surface. All major server libraries support this — in Apollo Server, set introspection: false.

GraphQL Security Checklist

ThreatMitigationImplementation
Deeply nested queriesQuery depth limitingSet maxDepth: 10 in validation rules
Expensive queriesQuery complexity analysisAssign cost per field, reject above threshold
Schema exposureDisable introspectionintrospection: false in production
Arbitrary queriesPersisted queries / allowlistOnly accept pre-registered query hashes
Rate limitingPer-operation rate limitsTrack cost per query, not just requests
Injection attacksInput validationValidate at resolver level + use parameterized DB queries

GraphQL Ecosystem and Tooling

Apollo Server

The most popular Node.js GraphQL server. Production-ready with plugins, caching, and federation.

Apollo Client

Full-featured React/JS client with normalized caching, optimistic UI, and subscription support.

GraphQL Yoga

Lightweight, spec-compliant server by The Guild. Built on standard Fetch API, runs anywhere.

urql

Minimal, extensible GraphQL client. Smaller bundle than Apollo, great for simpler use cases.

GraphQL Code Generator

Generates TypeScript types, React hooks, and resolvers from your schema. Eliminates runtime type errors.

Relay

Meta's GraphQL client for React. Compiler-driven with aggressive optimizations. Steeper learning curve.

Hasura / PostGraphile

Auto-generate a GraphQL API from your PostgreSQL database. Instant CRUD, subscriptions, and auth.

Apollo Federation

Compose multiple GraphQL services into one unified supergraph. The standard for microservice GraphQL.

Frequently Asked Questions

Is GraphQL a database query language?

No. GraphQL is an API query language — it sits between clients and your backend. It is database-agnostic and works with any data source: SQL databases, NoSQL stores, REST APIs, microservices, or even static files. The name is misleading; it has nothing to do with graph databases.

Does GraphQL replace REST?

Not necessarily. GraphQL and REST solve different problems. GraphQL excels when clients need flexible data fetching, multiple resources in one request, or real-time subscriptions. REST is simpler for basic CRUD, benefits from native HTTP caching, and has broader tooling support. Many organizations run both side by side.

How do you handle authentication in GraphQL?

Authentication is typically handled outside GraphQL — in middleware or the HTTP layer. The authenticated user is passed to resolvers via the context object. Authorization (who can access what) is enforced inside resolvers or with schema directives. Never put auth logic in the schema definition itself.

What is the N+1 problem in GraphQL?

The N+1 problem occurs when resolving nested fields triggers one database query per parent item. For example, fetching 50 posts with their authors results in 1 query for posts + 50 queries for authors. The solution is DataLoader, which batches individual lookups into a single query per request cycle.

Can GraphQL handle file uploads?

The GraphQL specification does not cover file uploads. The most common approach is the GraphQL multipart request specification, implemented by libraries like graphql-upload. Alternatively, many teams use a separate REST endpoint or pre-signed URLs for uploads and pass the resulting URL to a GraphQL mutation.

Is GraphQL slower than REST?

Not inherently. A well-optimized GraphQL API with DataLoader and persisted queries performs comparably to REST. GraphQL can actually be faster for mobile clients because it eliminates round trips and over-fetching. However, naive implementations without batching or complexity limits can be significantly slower due to the N+1 problem.

References