Ask for exactly what you need, get predictable results.
The complete GraphQL reference. Schema design, resolvers, queries, mutations, subscriptions, authentication, pagination, client libraries, and the tooling ecosystem.
The three root operation types at a glance. Every GraphQL API exposes a single endpoint that handles queries, mutations, and subscriptions.
query GetUser($id: ID!) {
user(id: $id) {
name
email
posts(first: 5) {
title
createdAt
}
}
}
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
author {
name
}
}
}
subscription OnNewMessage($channelId: ID!) {
messageAdded(channelId: $channelId) {
id
text
sender {
name
avatar
}
}
}
| Aspect | REST | GraphQL |
|---|---|---|
| Endpoints | Multiple (/users, /posts) | Single (/graphql) |
| Data shape | Server decides | Client decides |
| Over-fetching | Common | Eliminated |
| Under-fetching | Requires multiple requests | Nested in one query |
| Versioning | /v1/, /v2/ | Schema evolution (deprecate fields) |
| Type system | OpenAPI / ad hoc | Built-in, introspectable |
| Caching | HTTP caching (GET) | Normalized client cache |
The schema is the contract between client and server. Written in SDL (Schema Definition Language), it describes every type, field, and relationship in your API.
type User {
id: ID! # Non-nullable unique identifier
name: String! # Non-nullable string
email: String # Nullable string
age: Int # Nullable integer
score: Float # Floating point
active: Boolean! # Non-nullable boolean
posts: [Post!]! # Non-nullable list of non-nullable Posts
role: Role! # Enum value
}
! to make them non-nullable. [Post!]! means the list itself and every item in it are guaranteed non-null.
| Scalar | Description | Example |
|---|---|---|
Int | 32-bit signed integer | 42 |
Float | Double-precision float | 3.14 |
String | UTF-8 text | "hello" |
Boolean | True or false | true |
ID | Unique identifier (serialized as String) | "abc123" |
# Custom scalar types
scalar DateTime # ISO 8601 date-time string
scalar JSON # Arbitrary JSON blob
scalar URL # Validated URL string
enum Role {
ADMIN
EDITOR
VIEWER
}
enum SortOrder {
ASC
DESC
}
interface Node {
id: ID!
}
interface Timestamped {
createdAt: DateTime!
updatedAt: DateTime!
}
type User implements Node & Timestamped {
id: ID!
createdAt: DateTime!
updatedAt: DateTime!
name: String!
}
union SearchResult = User | Post | Comment
type Query {
search(term: String!): [SearchResult!]!
}
# Querying a union requires inline fragments:
query {
search(term: "graphql") {
... on User { name, email }
... on Post { title, body }
... on Comment { text }
}
}
input CreateUserInput {
name: String!
email: String!
role: Role = VIEWER # Default value
}
input UpdateUserInput {
name: String # All fields optional for partial updates
email: String
role: Role
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
}
# Built-in directives
query GetUser($withPosts: Boolean!, $skipEmail: Boolean!) {
user(id: "1") {
name
email @skip(if: $skipEmail)
posts @include(if: $withPosts) {
title
}
}
}
# Schema directives (custom)
directive @auth(requires: Role!) on FIELD_DEFINITION
type Query {
users: [User!]! @auth(requires: ADMIN)
}
Resolvers are functions that populate each field in your schema. They receive four arguments: parent, args, context, and info.
// fieldName(parent, args, context, info) => data
const resolvers = {
Query: {
// parent is the root value (usually empty)
user: (parent, { id }, context) => {
return context.db.users.findById(id);
},
users: (parent, { limit, offset }, context) => {
return context.db.users.findAll({ limit, offset });
},
},
User: {
// parent is the User object from the parent resolver
posts: (user, args, context) => {
return context.db.posts.findByAuthor(user.id);
},
// Computed field (no DB column)
fullName: (user) => {
return `${user.firstName} ${user.lastName}`;
},
},
};
| Argument | Also Called | Description |
|---|---|---|
parent | root, obj | Return value of the parent resolver (the object this field belongs to) |
args | — | Arguments passed to the field in the query |
context | ctx | Shared per-request object (auth, DB connections, loaders) |
info | — | AST metadata about the query (field selection, path, etc.) |
Without batching, fetching a list of users and their posts triggers N+1 queries: 1 for the user list, then 1 per user for their posts.
// Query: { users { posts { title } } }
// SQL executed:
SELECT * FROM users -- 1 query
SELECT * FROM posts WHERE author_id = 1 -- N queries
SELECT * FROM posts WHERE author_id = 2
SELECT * FROM posts WHERE author_id = 3
// ... one per user
// With DataLoader, all IDs are collected
// and batched into a single query:
SELECT * FROM users -- 1 query
SELECT * FROM posts
WHERE author_id IN (1, 2, 3) -- 1 query
// Total: 2 queries regardless of N
import DataLoader from 'dataloader';
// Create loader per-request (in context factory)
const createLoaders = (db) => ({
postsByAuthor: new DataLoader(async (authorIds) => {
const posts = await db.posts.findAll({
where: { authorId: authorIds }
});
// Must return array in same order as input IDs
return authorIds.map(id =>
posts.filter(p => p.authorId === id)
);
}),
});
// Resolver uses the loader
User: {
posts: (user, args, context) => {
return context.loaders.postsByAuthor.load(user.id);
},
}
const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ req }) => ({
// Authentication
user: getUserFromToken(req.headers.authorization),
// Database
db: prisma,
// DataLoaders (fresh per request)
loaders: createLoaders(prisma),
}),
});
Queries are read operations. GraphQL provides powerful features for composing exactly the data you need in a single request.
# Schema
type Query {
user(id: ID!): User
users(
role: Role
limit: Int = 10
offset: Int = 0
sortBy: String = "createdAt"
order: SortOrder = DESC
): [User!]!
}
# Query with arguments
query {
users(role: ADMIN, limit: 5) {
name
email
}
}
Variables decouple dynamic values from the query string. They are passed as a separate JSON object alongside the query.
# Query with variable declarations
query GetUsers(
$role: Role!
$limit: Int = 10
) {
users(role: $role, limit: $limit) {
name
email
}
}
# Variables JSON (sent alongside query):
{
"role": "ADMIN",
"limit": 20
}
Aliases let you query the same field multiple times with different arguments in a single request.
query {
admins: users(role: ADMIN) {
name
email
}
editors: users(role: EDITOR) {
name
email
}
}
# Response:
{
"admins": [{ "name": "Alice", ... }],
"editors": [{ "name": "Bob", ... }]
}
Fragments define reusable sets of fields. They reduce duplication when the same fields are needed across multiple queries or inline selections.
# Define a fragment
fragment UserFields on User {
id
name
email
avatar
}
# Use in multiple places
query {
me {
...UserFields
settings { theme }
}
user(id: "42") {
...UserFields
posts { title }
}
}
# Inline fragments (for unions/interfaces)
query {
search(term: "hello") {
__typename
... on User { name }
... on Post { title, body }
}
}
# Named operations (required when sending multiple in one document)
query GetDashboard {
me { name }
notifications(unread: true) { count }
recentPosts(limit: 5) { title, createdAt }
}
# HTTP request specifies which operation to execute:
{
"query": "query GetDashboard { ... } query GetProfile { ... }",
"operationName": "GetDashboard"
}
Mutations are write operations. They modify server-side data and return the updated state. Unlike queries, mutation fields execute sequentially.
The single input argument pattern keeps mutation signatures clean and makes schema evolution easier.
input CreatePostInput {
title: String!
body: String!
tags: [String!]
published: Boolean = false
}
type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload!
}
Return a dedicated payload type instead of the raw entity. This gives room for metadata, errors, and future fields without breaking changes.
type CreatePostPayload {
post: Post # Nullable (null on failure)
errors: [UserError!]! # Business logic errors
}
type UserError {
field: String # Which input field caused the error
message: String! # Human-readable message
code: ErrorCode! # Machine-readable code
}
enum ErrorCode {
NOT_FOUND
VALIDATION_ERROR
PERMISSION_DENIED
ALREADY_EXISTS
}
type Mutation {
# Create
createUser(input: CreateUserInput!): CreateUserPayload!
# Update (partial)
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
# Delete
deleteUser(id: ID!): DeleteUserPayload!
# Bulk operations
deleteUsers(ids: [ID!]!): DeleteUsersPayload!
}
# Executing a mutation
mutation {
createUser(input: {
name: "Alice"
email: "alice@example.com"
role: EDITOR
}) {
post {
id
name
}
errors {
field
message
}
}
}
Return enough data from mutations to update the client cache without an extra query.
# Bad: only returning the ID
mutation { updatePost(id: "1", input: { title: "New" }) { id } }
# Good: return all fields the UI needs
mutation {
updatePost(id: "1", input: { title: "New Title" }) {
post {
id
title
updatedAt
author { name }
}
errors { message }
}
}
Subscriptions push real-time data to clients over persistent connections. The most common transport is WebSocket via the graphql-ws protocol.
type Subscription {
messageAdded(channelId: ID!): Message!
userStatusChanged: UserStatus!
postPublished: Post!
}
type Message {
id: ID!
text: String!
sender: User!
createdAt: DateTime!
}
type UserStatus {
user: User!
online: Boolean!
lastSeen: DateTime
}
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
const resolvers = {
Mutation: {
sendMessage: async (_, { channelId, text }, ctx) => {
const message = await ctx.db.messages.create({
channelId, text, senderId: ctx.user.id
});
// Publish event to all subscribers
pubsub.publish(`MESSAGE_ADDED_${channelId}`, {
messageAdded: message,
});
return message;
},
},
Subscription: {
messageAdded: {
subscribe: (_, { channelId }) =>
pubsub.asyncIterator(`MESSAGE_ADDED_${channelId}`),
},
},
};
// Apollo Client subscription
import { useSubscription, gql } from '@apollo/client';
const NEW_MESSAGES = gql`
subscription OnNewMessage($channelId: ID!) {
messageAdded(channelId: $channelId) {
id
text
sender { name avatar }
}
}
`;
function ChatRoom({ channelId }) {
const { data, loading } = useSubscription(NEW_MESSAGES, {
variables: { channelId },
});
if (data) {
// New message received
console.log(data.messageAdded);
}
}
| Protocol | Library | Status |
|---|---|---|
graphql-ws | graphql-ws | Recommended |
subscriptions-transport-ws | subscriptions-transport-ws | Deprecated |
| Server-Sent Events | graphql-sse | Alternative |
PubSub only works for single-server deployments. For production, use Redis-backed pubsub (graphql-redis-subscriptions) or a message broker like Kafka.
Authentication verifies who the user is. Authorization decides what they can access. GraphQL handles both through context and resolver-level checks.
// Extract user from JWT in the context factory
const server = new ApolloServer({
typeDefs,
resolvers,
context: async ({ req }) => {
const token = req.headers.authorization?.replace('Bearer ', '');
let user = null;
if (token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
user = await db.users.findById(decoded.userId);
} catch (e) {
// Token invalid or expired — user stays null
}
}
return { user, db, loaders: createLoaders(db) };
},
});
const resolvers = {
Query: {
// Public — no auth check
publicPosts: (_, args, ctx) => {
return ctx.db.posts.findPublic();
},
// Authenticated — any logged-in user
me: (_, args, ctx) => {
if (!ctx.user) throw new AuthenticationError('Not authenticated');
return ctx.user;
},
// Role-based — admin only
allUsers: (_, args, ctx) => {
if (!ctx.user) throw new AuthenticationError('Not authenticated');
if (ctx.user.role !== 'ADMIN') throw new ForbiddenError('Admin only');
return ctx.db.users.findAll();
},
},
};
# Schema
directive @auth(requires: Role = VIEWER) on FIELD_DEFINITION | OBJECT
type Query {
me: User @auth
users: [User!]! @auth(requires: ADMIN)
analytics: Analytics! @auth(requires: ADMIN)
posts: [Post!]! # No directive = public
}
// Directive implementation (graphql-tools)
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
function authDirective(schema) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const directive = getDirective(schema, fieldConfig, 'auth')?.[0];
if (!directive) return;
const { resolve } = fieldConfig;
const requiredRole = directive.requires || 'VIEWER';
fieldConfig.resolve = (parent, args, ctx, info) => {
if (!ctx.user) throw new Error('Not authenticated');
if (!hasRole(ctx.user, requiredRole)) {
throw new Error(`Requires ${requiredRole} role`);
}
return resolve(parent, args, ctx, info);
};
},
});
}
User: {
// Only the user themselves or admins can see email
email: (user, args, ctx) => {
if (ctx.user?.id === user.id || ctx.user?.role === 'ADMIN') {
return user.email;
}
return null;
},
// Mask sensitive data for non-owners
phone: (user, args, ctx) => {
if (ctx.user?.id !== user.id) return '***-***-' + user.phone.slice(-4);
return user.phone;
},
}
GraphQL supports multiple pagination patterns. Cursor-based pagination (Relay spec) is the most robust approach for dynamic datasets.
type Query {
posts(limit: Int = 10, offset: Int = 0): [Post!]!
}
# Usage: posts(limit: 10, offset: 20) → items 21-30
The Relay Connection specification defines a standard structure for paginated data with cursors, page info, and edges.
# Connection types (Relay spec)
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post! # The actual item
cursor: String! # Opaque cursor string
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
posts(
first: Int # Forward pagination count
after: String # Cursor: fetch items after this
last: Int # Backward pagination count
before: String # Cursor: fetch items before this
): PostConnection!
}
# First page
query {
posts(first: 10) {
edges {
node { id, title }
cursor
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
# Next page (use endCursor from previous response)
query {
posts(first: 10, after: "Y3Vyc29yOjEw") {
edges { node { id, title } }
pageInfo { hasNextPage, endCursor }
}
}
const resolvers = {
Query: {
posts: async (_, { first = 10, after }, ctx) => {
// Decode opaque cursor to get the offset/ID
const afterId = after ? decodeCursor(after) : null;
// Fetch one extra to determine hasNextPage
const posts = await ctx.db.posts.findMany({
where: afterId ? { id: { gt: afterId } } : {},
take: first + 1,
orderBy: { id: 'asc' },
});
const hasNextPage = posts.length > first;
const edges = posts.slice(0, first).map(post => ({
node: post,
cursor: encodeCursor(post.id),
}));
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor,
},
totalCount: await ctx.db.posts.count(),
};
},
},
};
// Cursor encoding (base64 is a common convention)
const encodeCursor = (id) => Buffer.from(`cursor:${id}`).toString('base64');
const decodeCursor = (cursor) => Buffer.from(cursor, 'base64').toString().split(':')[1];
GraphQL clients handle query execution, caching, and state management. Choose based on your needs: full-featured (Apollo), lightweight (urql), or minimal (graphql-request).
| Feature | Apollo Client | urql | graphql-request |
|---|---|---|---|
| Bundle size | ~40 KB | ~12 KB | ~5 KB |
| Normalized cache | Built-in | Plugin (Graphcache) | None |
| Subscriptions | Built-in | Plugin | None |
| SSR support | Built-in | Built-in | Manual |
| Devtools | Chrome extension | Chrome extension | None |
| Framework bindings | React, Vue, Angular, Svelte | React, Vue, Svelte | Framework-agnostic |
| Learning curve | Higher | Moderate | Minimal |
import { ApolloClient, InMemoryCache, gql, useQuery } from '@apollo/client';
// Setup
const client = new ApolloClient({
uri: 'https://api.example.com/graphql',
cache: new InMemoryCache(),
headers: {
authorization: `Bearer ${token}`,
},
});
// React hook
const GET_POSTS = gql`
query GetPosts($first: Int!) {
posts(first: $first) {
edges { node { id title } }
pageInfo { hasNextPage endCursor }
}
}
`;
function PostList() {
const { loading, error, data, fetchMore } = useQuery(GET_POSTS, {
variables: { first: 10 },
});
if (loading) return 'Loading...';
if (error) return `Error: ${error.message}`;
return (
<div>
{data.posts.edges.map(({ node }) => (
<PostCard key={node.id} post={node} />
))}
{data.posts.pageInfo.hasNextPage && (
<button onClick={() => fetchMore({
variables: { after: data.posts.pageInfo.endCursor }
})}>Load More</button>
)}
</div>
);
}
import { createClient, Provider, useQuery } from 'urql';
const client = createClient({
url: 'https://api.example.com/graphql',
fetchOptions: () => ({
headers: { authorization: `Bearer ${getToken()}` },
}),
});
// React component
function UserProfile({ id }) {
const [result] = useQuery({
query: `query ($id: ID!) { user(id: $id) { name email } }`,
variables: { id },
});
const { data, fetching, error } = result;
if (fetching) return 'Loading...';
return <div>{data.user.name}</div>;
}
import { GraphQLClient, gql } from 'graphql-request';
const client = new GraphQLClient('https://api.example.com/graphql', {
headers: { authorization: `Bearer ${token}` },
});
// Simple query
const data = await client.request(gql`
query {
users { id name email }
}
`);
// With variables
const user = await client.request(
gql`query GetUser($id: ID!) { user(id: $id) { name } }`,
{ id: '42' }
);
GraphQL Code Generator creates TypeScript types and typed hooks from your schema and operations.
# Install
npm install -D @graphql-codegen/cli \
@graphql-codegen/typescript \
@graphql-codegen/typescript-operations \
@graphql-codegen/typescript-react-apollo
# codegen.yml
schema: "https://api.example.com/graphql"
documents: "src/**/*.graphql"
generates:
src/generated/graphql.ts:
plugins:
- typescript
- typescript-operations
- typescript-react-apollo
# Run
npx graphql-codegen
// Result: fully typed hooks
import { useGetPostsQuery } from './generated/graphql';
// data.posts is fully typed with no manual type annotations
GraphQL is self-documenting through its introspection system. This enables powerful developer tools for exploring, testing, and composing schemas.
# Get all types in the schema
query {
__schema {
types {
name
kind
description
}
}
}
# Get fields for a specific type
query {
__type(name: "User") {
name
fields {
name
type {
name
kind
ofType { name }
}
args {
name
type { name }
defaultValue
}
}
}
}
# The __typename meta-field (available on every type)
query {
search(term: "hello") {
__typename
... on User { name }
... on Post { title }
}
}
introspection: false in your server config.
| Tool | Type | Description |
|---|---|---|
| GraphiQL | In-browser IDE | Official GraphQL Foundation explorer. Auto-complete, docs panel, history. |
| Apollo Sandbox | Cloud IDE | Apollo Studio’s hosted explorer. Schema registry, operation collections, mocking. |
| Altair | Desktop / Browser | Feature-rich client. File upload, subscriptions, environments, plugins. |
| Insomnia | Desktop | REST + GraphQL client. Environment management, code generation. |
| Postman | Desktop / Cloud | GraphQL support with auto-schema fetching and variable management. |
For large organizations, a single monolithic schema becomes unwieldy. Federation and stitching compose multiple subgraphs into a unified API.
# Users subgraph
type User @key(fields: "id") {
id: ID!
name: String!
}
# Posts subgraph
type User @key(fields: "id") {
id: ID!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
author: User!
}
import { stitchSchemas }
from '@graphql-tools/stitch';
const gateway = stitchSchemas({
subschemas: [
{
schema: usersSchema,
executor: usersExecutor,
},
{
schema: postsSchema,
executor: postsExecutor,
},
],
});
| Approach | Best For | Gateway |
|---|---|---|
| Federation | Microservices, team autonomy | Apollo Router / Gateway |
| Stitching | Composing third-party APIs | Custom Node.js server |
| Monolith | Small teams, simple apps | Single server |
# graphql-eslint — lint GraphQL schemas and operations
npm install -D @graphql-eslint/eslint-plugin
# .eslintrc.js
{
overrides: [{
files: ['*.graphql'],
parser: '@graphql-eslint/eslint-plugin',
plugins: ['@graphql-eslint'],
rules: {
'@graphql-eslint/naming-convention': ['error', {
types: 'PascalCase',
FieldDefinition: 'camelCase',
EnumValueDefinition: 'UPPER_CASE',
}],
'@graphql-eslint/no-unreachable-types': 'error',
'@graphql-eslint/require-description': ['warn', {
types: true,
FieldDefinition: true,
}],
},
}],
}
# graphql-inspector — diff schemas, find breaking changes
npx graphql-inspector diff old-schema.graphql new-schema.graphql
# Output:
✖ Field 'User.email' was removed # BREAKING
✔ Field 'User.avatar' was added # SAFE
⚠ Field 'User.name' description changed # WARNING
import depthLimit from 'graphql-depth-limit';
new ApolloServer({
validationRules: [depthLimit(7)],
});
Prevents deeply nested queries from overloading resolvers.
import { createComplexityRule }
from 'graphql-query-complexity';
new ApolloServer({
validationRules: [
createComplexityRule({
maximumComplexity: 1000,
}),
],
});
Assigns cost to fields; rejects queries exceeding budget.
// Client sends hash instead of
// full query string
{
"extensions": {
"persistedQuery": {
"sha256Hash": "abc..."
}
}
}
Reduces bandwidth; prevents arbitrary query execution.
// Per-field or per-operation
// rate limiting
const limiter = rateLimit({
max: 100,
window: '15m',
keyGenerator: (ctx) =>
ctx.user?.id || ctx.ip,
});
Prevents abuse by limiting request frequency per user or IP.