GraphQL Complete Guide: Schema, Queries, Mutations & Best Practices
GraphQL has transformed how developers build and consume APIs. Instead of juggling dozens of REST endpoints, each returning fixed data structures, GraphQL lets clients ask for exactly the data they need in a single request. This guide covers everything from core concepts and schema design to advanced topics like authentication, pagination, the N+1 problem, and production performance tuning.
Table of Contents
- 1. What Is GraphQL
- 2. GraphQL vs REST
- 3. Core Concepts: Schema, Types, Operations
- 4. Schema Definition Language (SDL)
- 5. Queries in Depth
- 6. Mutations: Writing Data
- 7. Subscriptions for Real-Time Data
- 8. Resolvers and the Resolver Chain
- 9. Error Handling
- 10. Authentication and Authorization
- 11. Pagination: Cursor vs Offset
- 12. Batching, DataLoader, and the N+1 Problem
- 13. File Uploads
- 14. GraphQL with Node.js (Apollo Server)
- 15. GraphQL with React (Apollo Client)
- 16. Schema Design Best Practices
- 17. Performance and Security
- 18. Tools and Developer Experience
- 19. Frequently Asked Questions
1. What Is GraphQL
GraphQL is a query language for APIs and a server-side runtime for executing those queries against your data. It was developed internally at Facebook in 2012 and open-sourced in 2015. Unlike traditional APIs where the server dictates the shape of responses, GraphQL puts the client in control.
The three pillars of GraphQL are:
- A type system -- you define your data model with a strongly typed schema.
- A query language -- clients write queries that mirror the shape of the data they want back.
- A runtime -- the server validates queries against the schema and executes them by calling resolver functions.
# A simple GraphQL query
{
user(id: "42") {
name
email
posts {
title
publishedAt
}
}
}
# The response matches the query shape exactly
{
"data": {
"user": {
"name": "Alice",
"email": "alice@example.com",
"posts": [
{ "title": "Getting Started with GraphQL", "publishedAt": "2026-01-15" },
{ "title": "Advanced Schema Design", "publishedAt": "2026-02-01" }
]
}
}
}
2. GraphQL vs REST
REST and GraphQL solve the same fundamental problem -- enabling clients to communicate with servers -- but they approach it differently. Understanding the trade-offs helps you choose the right tool for your project.
| Aspect | REST | GraphQL |
|---|---|---|
| Endpoints | Multiple URLs, one per resource | Single endpoint for all operations |
| Data fetching | Server decides response shape | Client specifies exact fields needed |
| Over-fetching | Common -- endpoints return all fields | Eliminated -- clients request only what they need |
| Under-fetching | Common -- requires multiple requests | Eliminated -- nested data in one query |
| Versioning | URL versioning (v1, v2) or headers | Schema evolution with deprecation |
| Caching | HTTP caching built in (ETags, Cache-Control) | Requires custom caching strategies |
| Real-time | Polling or separate WebSocket API | Built-in subscriptions |
| Type safety | Optional (OpenAPI/Swagger) | Built in -- schema is the contract |
When to use GraphQL: Mobile apps needing minimal payloads, dashboards aggregating data from many sources, rapidly evolving frontends, and teams that want a strongly typed API contract.
When to stick with REST: Simple CRUD APIs, file-heavy services, public APIs where HTTP caching matters, and teams already comfortable with REST conventions. See our API Testing Complete Guide for thorough REST coverage.
3. Core Concepts: Schema, Types, Operations
Every GraphQL API is built on three pillars: a schema that defines the data model, types that describe the shape of each piece of data, and operations that clients use to read and write data.
The Schema
The schema is the contract between client and server. It defines every type, field, and operation your API supports. Clients can introspect the schema to discover what queries are possible -- this is what powers autocomplete in GraphQL tools.
Type System
GraphQL has several built-in type categories:
- Scalar types:
Int,Float,String,Boolean,ID-- the leaf values of any query. - Object types: Custom types with named fields (e.g.,
User,Post). - Enum types: A fixed set of allowed values (e.g.,
Role: ADMIN, EDITOR, VIEWER). - Input types: Special object types used as arguments for mutations.
- Interface and Union types: Abstract types for polymorphism.
Three Operations
- Query: Read data (analogous to GET).
- Mutation: Write data -- create, update, delete (analogous to POST/PUT/DELETE).
- Subscription: Real-time data pushed from server to client over WebSocket.
4. Schema Definition Language (SDL)
SDL is the syntax for writing GraphQL schemas. It is human-readable, language-agnostic, and serves as documentation, validation rules, and the type system all in one.
Scalar and Enum Types
# Custom scalar for dates
scalar DateTime
# Enum for user roles
enum Role {
ADMIN
EDITOR
VIEWER
}
# Enum for post status
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
Object Types
type User {
id: ID!
name: String!
email: String!
role: Role!
avatar: String
posts: [Post!]!
createdAt: DateTime!
}
type Post {
id: ID!
title: String!
content: String!
status: PostStatus!
author: User!
tags: [String!]!
comments: [Comment!]!
publishedAt: DateTime
createdAt: DateTime!
}
type Comment {
id: ID!
body: String!
author: User!
post: Post!
createdAt: DateTime!
}
The ! suffix means a field is non-nullable. [Post!]! means the list itself is non-null and every item in the list is non-null.
Input Types
# Input types are used for mutation arguments
input CreatePostInput {
title: String!
content: String!
tags: [String!]
status: PostStatus = DRAFT
}
input UpdatePostInput {
title: String
content: String
tags: [String!]
status: PostStatus
}
Root Types
type Query {
user(id: ID!): User
users(limit: Int = 10, offset: Int = 0): [User!]!
post(id: ID!): Post
posts(status: PostStatus, limit: Int = 10): [Post!]!
me: User
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
register(name: String!, email: String!, password: String!): AuthPayload!
login(email: String!, password: String!): AuthPayload!
}
type Subscription {
postPublished: Post!
commentAdded(postId: ID!): Comment!
}
type AuthPayload {
token: String!
user: User!
}
Working with JSON Data?
GraphQL APIs communicate using JSON. Use our JSON Formatter to pretty-print and inspect GraphQL responses, and validate payloads with our JSON Validator. Read our JSON Complete Guide for more.
5. Queries in Depth
Fields and Nesting
GraphQL queries mirror the shape of the response. You request fields, and the server returns exactly those fields -- nothing more, nothing less.
# Request specific fields and nested relations
query {
post(id: "1") {
title
content
author {
name
email
}
comments {
body
author {
name
}
}
}
}
Arguments
Every field can accept arguments. Arguments let you filter, sort, paginate, and customize the data returned.
query {
users(role: ADMIN, limit: 5) {
name
email
}
recentPosts: posts(status: PUBLISHED, limit: 3) {
title
publishedAt
}
}
Aliases
Aliases let you rename fields in the response, which is essential when querying the same field with different arguments.
query {
admin: user(id: "1") {
name
role
}
editor: user(id: "2") {
name
role
}
}
Fragments
Fragments are reusable sets of fields. They reduce duplication and keep queries maintainable.
fragment UserBasic on User {
id
name
email
role
}
query {
admin: user(id: "1") {
...UserBasic
posts { title }
}
editor: user(id: "2") {
...UserBasic
posts { title }
}
}
Variables
Variables separate the query structure from dynamic values. They prevent string interpolation (which risks injection attacks) and enable query caching.
# Query with variables
query GetUser($userId: ID!) {
user(id: $userId) {
name
email
posts {
title
}
}
}
# Variables (sent as separate JSON)
{
"userId": "42"
}
Directives
Built-in directives @include and @skip let you conditionally include fields based on variables.
query GetUser($userId: ID!, $withPosts: Boolean!) {
user(id: $userId) {
name
email
posts @include(if: $withPosts) {
title
publishedAt
}
}
}
6. Mutations: Writing Data
Mutations are how you create, update, and delete data in GraphQL. By convention, mutations return the modified object so the client cache can update automatically.
Creating Data
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
status
createdAt
author {
name
}
}
}
# Variables
{
"input": {
"title": "Introduction to GraphQL",
"content": "GraphQL is a query language for APIs...",
"tags": ["graphql", "api", "tutorial"]
}
}
Updating Data
mutation UpdatePost($id: ID!, $input: UpdatePostInput!) {
updatePost(id: $id, input: $input) {
id
title
status
content
}
}
# Variables
{
"id": "1",
"input": {
"status": "PUBLISHED",
"title": "Introduction to GraphQL (Updated)"
}
}
Deleting Data
mutation DeletePost($id: ID!) {
deletePost(id: $id)
}
# Variables
{ "id": "1" }
Best practice: mutations should use input types rather than long lists of individual arguments. This makes the API easier to evolve because adding a new optional field to an input type is a non-breaking change.
7. Subscriptions for Real-Time Data
Subscriptions let clients receive real-time updates when data changes on the server. Under the hood, they use WebSocket connections to push events as they occur.
# Client subscribes to new comments on a post
subscription OnCommentAdded($postId: ID!) {
commentAdded(postId: $postId) {
id
body
author {
name
avatar
}
createdAt
}
}
On the server side (Apollo Server), subscriptions use a publish/subscribe mechanism:
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
const resolvers = {
Mutation: {
addComment: async (_, { postId, body }, context) => {
const comment = await db.comments.create({
postId, body, authorId: context.user.id
});
// Publish the event
pubsub.publish(`COMMENT_ADDED_${postId}`, {
commentAdded: comment
});
return comment;
}
},
Subscription: {
commentAdded: {
subscribe: (_, { postId }) =>
pubsub.asyncIterableIterator(`COMMENT_ADDED_${postId}`)
}
}
};
When to use subscriptions: Live chat, notifications, collaborative editing, real-time dashboards, and live feeds. For infrequent updates, consider polling instead -- subscriptions add WebSocket overhead.
8. Resolvers and the Resolver Chain
Resolvers are functions that produce the data for each field in your schema. When a query arrives, GraphQL walks the query tree and calls the corresponding resolver for each field.
Resolver Signature
// Every resolver receives four arguments
const resolver = (parent, args, context, info) => {
// parent - the result from the parent resolver
// args - the arguments passed to this field
// context - shared across all resolvers (auth, db, etc.)
// info - AST and schema metadata for the query
};
Resolver Chain Example
const resolvers = {
Query: {
// Root resolver -- no parent
post: async (_, { id }, { db }) => {
return db.posts.findById(id);
}
},
Post: {
// Field resolver -- parent is the Post object
author: async (post, _, { db }) => {
return db.users.findById(post.authorId);
},
comments: async (post, _, { db }) => {
return db.comments.findByPostId(post.id);
}
},
Comment: {
author: async (comment, _, { db }) => {
return db.users.findById(comment.authorId);
}
}
};
GraphQL resolves fields top-down. For the query post { author { name } }, it first calls Query.post, then passes the result as parent to Post.author, then uses the default resolver for the scalar name field (which just returns parent.name).
Default Resolvers
You do not need to write a resolver for every field. If a field name matches a property on the parent object, GraphQL uses a default resolver that returns parent[fieldName]. Only write custom resolvers when you need to transform data, call a database, or enforce business logic.
9. Error Handling
GraphQL always returns HTTP 200, even when there are errors. Errors are reported in the errors array alongside any partial data that could be resolved successfully.
// Response with partial data and errors
{
"data": {
"user": {
"name": "Alice",
"email": null
}
},
"errors": [
{
"message": "Not authorized to view email",
"locations": [{ "line": 4, "column": 5 }],
"path": ["user", "email"],
"extensions": {
"code": "FORBIDDEN",
"statusCode": 403
}
}
]
}
Custom Error Classes
import { GraphQLError } from 'graphql';
// Throw structured errors in resolvers
throw new GraphQLError('Post not found', {
extensions: {
code: 'NOT_FOUND',
statusCode: 404,
postId: id
}
});
throw new GraphQLError('Not authorized', {
extensions: {
code: 'FORBIDDEN',
statusCode: 403
}
});
Best practices: Use the extensions.code field for machine-readable error codes. Keep error messages user-friendly. Never expose stack traces or internal details in production. Use formatError in your server config to sanitize errors before they reach the client.
10. Authentication and Authorization
Authentication: Who Are You?
Authentication is typically handled at the HTTP layer, before GraphQL resolvers execute. The server extracts a JWT or session token from the request headers and attaches the user to the context.
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import jwt from 'jsonwebtoken';
const server = new ApolloServer({ typeDefs, resolvers });
await server.start();
app.use('/graphql', expressMiddleware(server, {
context: async ({ req }) => {
const token = req.headers.authorization?.replace('Bearer ', '');
let user = null;
if (token) {
try {
user = jwt.verify(token, process.env.JWT_SECRET);
} catch (e) {
// Token invalid or expired -- user stays null
}
}
return { user, db };
}
}));
Authorization: What Can You Do?
Authorization is enforced inside resolvers. Each resolver checks whether the authenticated user has permission to access the requested data or perform the operation.
const resolvers = {
Mutation: {
deletePost: async (_, { id }, { user, db }) => {
if (!user) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' }
});
}
const post = await db.posts.findById(id);
if (!post) {
throw new GraphQLError('Post not found', {
extensions: { code: 'NOT_FOUND' }
});
}
if (post.authorId !== user.id && user.role !== 'ADMIN') {
throw new GraphQLError('Not authorized to delete this post', {
extensions: { code: 'FORBIDDEN' }
});
}
await db.posts.delete(id);
return true;
}
}
};
For larger APIs, consider using schema directives like @auth(role: ADMIN) to declaratively enforce permissions without repeating authorization logic in every resolver.
11. Pagination: Cursor vs Offset
Offset-Based Pagination
Offset pagination is straightforward: the client sends limit and offset parameters. However, it has problems with real-time data -- inserting or deleting items shifts offsets, causing duplicates or missed items.
query {
posts(limit: 10, offset: 20) {
id
title
publishedAt
}
}
Cursor-Based Pagination (Relay Style)
Cursor-based pagination uses opaque cursors to mark positions in the dataset. The Relay Connection specification is the standard approach in GraphQL.
# Schema for cursor-based pagination
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
posts(first: Int, after: String, last: Int, before: String): PostConnection!
}
# Query for the first page
query {
posts(first: 10) {
edges {
node { id, title, publishedAt }
cursor
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
# Query for the next page using the cursor
query {
posts(first: 10, after: "Y3Vyc29yOjEw") {
edges {
node { id, title, publishedAt }
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
Recommendation: Use cursor-based pagination for production APIs. It handles real-time data correctly, performs well on large datasets, and is the convention expected by client libraries like Relay.
12. Batching, DataLoader, and the N+1 Problem
The N+1 problem is the most common performance pitfall in GraphQL. When you query a list of 50 posts and each post resolves its author, you get 1 query for posts + 50 individual queries for authors = 51 database queries.
The Problem
// Naive resolver -- causes N+1 queries
const resolvers = {
Post: {
author: async (post, _, { db }) => {
// Called once per post in the list!
return db.users.findById(post.authorId);
}
}
};
The Solution: DataLoader
Facebook's DataLoader library batches and caches database calls within a single request. It collects all IDs requested during one tick of the event loop, then makes a single batched query.
import DataLoader from 'dataloader';
// Create a batch function
const batchUsers = async (userIds) => {
const users = await db.users.findByIds(userIds);
// Must return results in the same order as the input IDs
const userMap = new Map(users.map(u => [u.id, u]));
return userIds.map(id => userMap.get(id) || null);
};
// Create a new DataLoader per request (in context)
const context = ({ req }) => ({
user: getUser(req),
loaders: {
user: new DataLoader(batchUsers),
post: new DataLoader(batchPosts)
}
});
// Use the loader in resolvers
const resolvers = {
Post: {
author: (post, _, { loaders }) => {
return loaders.user.load(post.authorId);
// Now 50 posts = 1 batched query instead of 50
}
}
};
Key rules: Create a new DataLoader instance per request to prevent data leaking between users. The batch function must return results in the same order as the input keys. DataLoader automatically deduplicates repeated keys within the same request.
13. File Uploads
GraphQL does not natively support file uploads in the spec, but the community-standard graphql-multipart-request-spec enables it through multipart form data.
# Schema
scalar Upload
type Mutation {
uploadAvatar(file: Upload!): String! # Returns the URL
uploadAttachments(files: [Upload!]!): [String!]!
}
// Server-side resolver (Apollo Server)
const resolvers = {
Mutation: {
uploadAvatar: async (_, { file }) => {
const { createReadStream, filename, mimetype } = await file;
// Validate file type
if (!['image/jpeg', 'image/png', 'image/webp'].includes(mimetype)) {
throw new GraphQLError('Invalid file type');
}
const stream = createReadStream();
const path = `uploads/avatars/${Date.now()}-${filename}`;
await saveToStorage(stream, path);
return `https://cdn.example.com/${path}`;
}
}
};
Alternative approach: Many teams prefer a two-step pattern -- first get a signed upload URL via a mutation, then upload the file directly to cloud storage (S3, GCS) using that URL. This keeps large binary data out of the GraphQL layer entirely.
14. GraphQL with Node.js (Apollo Server)
Apollo Server is the most popular GraphQL server for Node.js. Here is a complete setup:
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
const typeDefs = `#graphql
type Book {
id: ID!
title: String!
author: String!
year: Int
}
type Query {
books: [Book!]!
book(id: ID!): Book
}
type Mutation {
addBook(title: String!, author: String!, year: Int): Book!
}
`;
const books = [
{ id: '1', title: 'The GraphQL Guide', author: 'John Resig', year: 2024 },
{ id: '2', title: 'Learning GraphQL', author: 'Eve Porcello', year: 2023 }
];
const resolvers = {
Query: {
books: () => books,
book: (_, { id }) => books.find(b => b.id === id)
},
Mutation: {
addBook: (_, { title, author, year }) => {
const book = { id: String(books.length + 1), title, author, year };
books.push(book);
return book;
}
}
};
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 }
});
console.log(`GraphQL server running at ${url}`);
For production apps, use expressMiddleware to integrate Apollo Server with Express, enabling middleware for authentication, CORS, rate limiting, and logging.
15. GraphQL with React (Apollo Client)
Apollo Client is the leading GraphQL client for React. It handles data fetching, caching, and UI state in one package.
// Setup Apollo Client
import { ApolloClient, InMemoryCache, ApolloProvider, gql,
useQuery, useMutation } from '@apollo/client';
const client = new ApolloClient({
uri: 'https://api.example.com/graphql',
cache: new InMemoryCache(),
headers: {
authorization: `Bearer ${localStorage.getItem('token')}`
}
});
// Wrap your app
function App() {
return (
<ApolloProvider client={client}>
<PostList />
</ApolloProvider>
);
}
// useQuery hook for reading data
const GET_POSTS = gql`
query GetPosts($status: PostStatus) {
posts(status: $status) {
id, title
author { name }
publishedAt
}
}
`;
function PostList() {
const { loading, error, data } = useQuery(GET_POSTS, {
variables: { status: 'PUBLISHED' }
});
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<div>
{data.posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>By {post.author.name}</p>
</article>
))}
</div>
);
}
// useMutation hook for writing data
const CREATE_POST = gql`
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) { id, title, status }
}
`;
function CreatePostForm() {
const [createPost, { loading, error }] = useMutation(CREATE_POST, {
refetchQueries: [{ query: GET_POSTS }]
});
const handleSubmit = async (e) => {
e.preventDefault();
await createPost({
variables: {
input: { title: 'New Post', content: 'Hello GraphQL!' }
}
});
};
return (
<form onSubmit={handleSubmit}>
{error && <p>Error: {error.message}</p>}
<button disabled={loading}>
{loading ? 'Creating...' : 'Create Post'}
</button>
</form>
);
}
Apollo Client's InMemoryCache normalizes data by __typename and id, so updating a post via mutation automatically updates it everywhere in the UI without manual cache manipulation. For more React patterns, see our Next.js Complete Guide.
16. Schema Design Best Practices
- Design for the client: Start with the UI and work backward. Your schema should make common queries simple, not mirror your database tables.
- Use input types for mutations: Wrap mutation arguments in an input type. This makes adding optional fields a non-breaking change.
- Return the modified object: Mutations should return the created or updated object so clients can update their cache without a separate query.
- Use connections for lists: Follow the Relay Connection spec (edges, nodes, pageInfo) for any list that might need pagination.
- Deprecate, don't remove: Mark fields with
@deprecated(reason: "Use newField instead")before removing them. This gives clients time to migrate. - Use enums for finite sets: Prefer
enum Status { ACTIVE, INACTIVE }overStringfor fields with a known set of values. - Avoid deeply nested mutations: Keep mutations flat. Instead of
updateUser(posts: { add: [...] }), use separateaddPostandremovePostmutations. - Consistent naming: Use camelCase for fields and arguments, PascalCase for types, and UPPER_CASE for enum values.
- Nullable by default: Only use
!(non-null) when you are certain a field will always have a value. Making a non-null field nullable later is a breaking change.
For TypeScript projects, tools like GraphQL Code Generator can produce type-safe hooks and types directly from your schema. See our TypeScript Generics Guide for more on building type-safe applications.
17. Performance and Security
Query Complexity Analysis
Assign a cost to each field and reject queries that exceed a budget. This prevents clients from requesting the entire graph in one query.
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 10,
listFactor: 20
})
]
});
Query Depth Limiting
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(7)]
// Rejects queries nested deeper than 7 levels
});
Persisted Queries
In production, replace ad-hoc query strings with pre-registered query hashes. The client sends a hash instead of the full query text, and the server looks up the pre-approved query. This reduces bandwidth, prevents arbitrary queries, and enables better caching.
// Client sends:
{
"extensions": {
"persistedQuery": {
"sha256Hash": "abc123def456..."
}
}
}
// Server looks up the query by hash and executes it
Caching Strategies
- Response caching: Cache full responses for identical queries using a CDN or server-side cache.
- Field-level caching: Use
@cacheControl(maxAge: 300)directives to set TTLs per field. - DataLoader caching: DataLoader caches within a single request. For cross-request caching, use Redis or Memcached in your batch functions.
- Normalized client cache: Apollo Client's
InMemoryCachenormalizes entities by__typename:id, so updating one entity updates it across all queries.
Security Checklist
- Disable introspection in production (
introspection: false). - Set query depth and complexity limits.
- Rate limit by client IP or API key.
- Use persisted queries to whitelist allowed operations.
- Validate and sanitize all input arguments.
- Set request size limits to prevent oversized payloads.
- Log and monitor query patterns for anomalies.
18. Tools and Developer Experience
| Tool | Purpose | Best For |
|---|---|---|
| GraphiQL | In-browser IDE for GraphQL | Exploring and testing queries interactively |
| Apollo Studio | Cloud platform for GraphQL | Schema registry, performance monitoring, collaboration |
| Apollo Sandbox | Free query explorer | Quick testing without a full Studio account |
| GraphQL Voyager | Schema visualization | Understanding complex schemas as interactive graphs |
| GraphQL Code Generator | Type generation from schemas | TypeScript types, React hooks, resolvers from SDL |
| Postman | API testing platform | GraphQL support with collections and environments |
| Altair GraphQL Client | Desktop/browser GraphQL IDE | File uploads, subscriptions, multiple windows |
All GraphQL APIs communicate using JSON. When debugging responses or crafting test payloads, use our JSON Formatter to pretty-print nested data and quickly spot issues in deeply nested query results.
19. Frequently Asked Questions
What is GraphQL and how does it differ from REST?
GraphQL is a query language and runtime for APIs developed by Facebook. Unlike REST, which uses multiple endpoints with fixed data structures, GraphQL exposes a single endpoint where clients specify exactly what data they need. This eliminates over-fetching and under-fetching. GraphQL uses a strongly typed schema, supports real-time subscriptions, and lets clients request nested, related data in a single query.
What is the N+1 problem in GraphQL and how do you solve it?
The N+1 problem occurs when a GraphQL resolver fetches a list of N items, then makes an additional database query for each item to resolve a related field -- resulting in N+1 total queries. The standard solution is Facebook's DataLoader library, which batches and caches database calls. DataLoader collects all IDs requested during a single tick of the event loop and makes one batched query instead of N individual queries.
Should I use cursor-based or offset-based pagination in GraphQL?
Cursor-based pagination is generally recommended for GraphQL APIs. It uses opaque cursors (typically base64-encoded IDs) to mark positions in the dataset, following the Relay Connection specification with edges, nodes, and pageInfo. Cursor-based pagination handles real-time data changes gracefully and performs well on large datasets. Offset-based pagination is simpler but can skip or duplicate items when data changes between requests.
How do you handle authentication and authorization in GraphQL?
Authentication is handled at the HTTP layer using JWTs or session tokens passed in headers. The server verifies the token in middleware and attaches the user to the context object, which is available in every resolver. Authorization is enforced at the resolver level -- each resolver checks whether the authenticated user has permission to access the requested field or perform the requested mutation. Schema directives like @auth can declaratively enforce permissions.
How do you prevent malicious or expensive GraphQL queries?
Protect your GraphQL API with query depth limiting, query complexity analysis (assign cost values to fields), rate limiting, timeouts, persisted queries (only allow pre-approved query hashes), and disabling introspection in production. These measures prevent clients from requesting the entire graph in one query or running denial-of-service attacks through deeply nested or computationally expensive queries.
What are GraphQL subscriptions and when should I use them?
GraphQL subscriptions provide real-time data updates from the server to connected clients over WebSocket connections. When a client subscribes to an event, the server pushes data whenever that event occurs. Use subscriptions for live chat, real-time notifications, collaborative editing, live dashboards, and any scenario where users need to see changes immediately without polling.
Conclusion
GraphQL gives frontend teams the power to ask for exactly the data they need, and backend teams a strongly typed contract that serves as living documentation. Starting with a well-designed schema, implementing resolvers with DataLoader for efficient data loading, and securing the API with depth limits, complexity analysis, and proper authentication will set you up for a production-grade GraphQL service.
The ecosystem is mature: Apollo Server and Apollo Client handle the heavy lifting on both sides, tools like GraphiQL and Apollo Studio make development interactive, and patterns like cursor-based pagination and persisted queries solve the common scaling challenges. Whether you are building a new API from scratch or wrapping existing REST services, GraphQL is a proven choice for modern application development.
Build and Debug GraphQL APIs
GraphQL responses are JSON -- format and inspect them with our JSON Formatter. For API testing workflows, see our API Testing Complete Guide. Working with JSON data? Read the JSON Complete Guide for a deep dive into the format that powers every GraphQL API.