GraphQL
Schema design, resolver patterns, performance, and security for production GraphQL APIs.
When to Activate
- Designing types, queries, mutations, or subscriptions in a GraphQL schema
- Implementing resolvers in Python (Strawberry/Ariadne), TypeScript (Apollo/Pothos), or Go (gqlgen)
- Solving N+1 query problems with DataLoader
- Paginating results with Cursor Connections
- Securing a GraphQL endpoint against introspection, depth attacks, or query abuse
- Choosing between GraphQL and REST for a new API
- Setting up real-time updates with subscriptions
GraphQL vs. REST
| Concern | GraphQL | REST |
|---|---|---|
| Data fetching | Client specifies exact fields | Server defines response shape |
| Multiple resources | Single request | One request per resource |
| Versioning | Schema evolves via deprecation | URL or header versioning |
| Caching | Complex (query-level) | Simple (HTTP cache headers) |
| File uploads | Non-standard | Native multipart |
| Best for | Flexible client needs, multiple consumers | Simple CRUD, public APIs, CDN caching |
Use GraphQL when you have multiple clients (web, mobile, third-party) with different data needs. Prefer REST for simple CRUD with aggressive HTTP caching.
Schema Design
Type Conventions
# Scalar types
scalar DateTime # ISO-8601 string
scalar UUID
scalar JSON
# Object type — PascalCase, fields camelCase
type User {
id: ID!
email: String!
createdAt: DateTime!
orders(first: Int, after: String): OrderConnection!
}
# Input type — suffix with Input
input CreateUserInput {
email: String!
name: String!
}
# Enum — SCREAMING_SNAKE_CASE values
enum OrderStatus {
PENDING
PROCESSING
COMPLETED
CANCELLED
}
# Interface — shared fields across types
interface Node {
id: ID!
}
# Union — one of several types
union SearchResult = User | Product | Order
Nullable vs. Non-Null
| Pattern | Schema | When to use |
|---|---|---|
| Always present | field: String! |
Required data — fetch fails if missing |
| Optional | field: String |
May legitimately be absent |
| List always present | items: [Item!]! |
List itself and items always exist |
| List may be absent | items: [Item!] |
Null means "not loaded", [] means "empty" |
Prefer non-null (!) for fields that are always present. Nullable fields force every client to null-check; use them only when absence is meaningful.
Mutations
# BAD — returns the raw type
type Mutation {
createUser(email: String!, name: String!): User
}
# GOOD — dedicated payload type with errors
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
deleteUser(id: ID!): DeleteUserPayload!
}
type CreateUserPayload {
user: User # null on failure
errors: [UserError!]!
}
type UserError {
field: String # null for non-field errors
message: String!
code: String!
}
Mutation payload types give clients a typed error path without relying on the errors top-level array.
Cursor Connections (Pagination)
Use the Relay Cursor Connection spec for all list fields — it handles forward, backward, and arbitrary pagination consistently.
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
users(first: Int, after: String, last: Int, before: String): UserConnection!
}
# Python — cursor is base64(type:id)
import base64
def encode_cursor(type_name: str, id: str) -> str:
return base64.b64encode(f"{type_name}:{id}".encode()).decode()
def decode_cursor(cursor: str) -> tuple[str, str]:
decoded = base64.b64decode(cursor.encode()).decode()
type_name, id = decoded.split(":", 1)
return type_name, id
Resolvers and the N+1 Problem
The Problem
Query: { users { id orders { id total } } }
Naive resolver:
SELECT * FROM users — 1 query
SELECT * FROM orders WHERE user_id=1 — 1 query per user
SELECT * FROM orders WHERE user_id=2
SELECT * FROM orders WHERE user_id=3 — N queries for N users = N+1 total
DataLoader (Batch + Cache)
// TypeScript — Apollo Server with DataLoader
import DataLoader from "dataloader";
// One loader per request — never share across requests
function createLoaders() {
return {
ordersByUserId: new DataLoader<string, Order[]>(async (userIds) => {
const orders = await db.order.findMany({
where: { userId: { in: [...userIds] } },
});
// Return results in the same order as keys
return userIds.map((id) => orders.filter((o) => o.userId === id));
}),
};
}
// Resolver
const resolvers = {
User: {
orders: (user, _args, { loaders }) =>
loaders.ordersByUserId.load(user.id), // batched automatically
},
};
# Python — Strawberry with strawberry-django DataLoader
from strawberry.dataloader import DataLoader
async def load_orders(user_ids: list[str]) -> list[list[Order]]:
orders = await Order.objects.filter(user_id__in=user_ids).all()
mapping: dict[str, list[Order]] = {id: [] for id in user_ids}
for order in orders:
mapping[order.user_id].append(order)
return [mapping[id] for id in user_ids]
orders_loader = DataLoader(load_fn=load_orders)
// Go — gqlgen with graph-gophers/dataloader
loader := dataloader.NewBatchedLoader(func(ctx context.Context, keys dataloader.Keys) []*dataloader.Result {
ids := make([]string, len(keys))
for i, k := range keys { ids[i] = k.String() }
orders, _ := db.FindOrdersByUserIDs(ctx, ids)
// Map results back to key order
results := make([]*dataloader.Result, len(keys))
orderMap := groupByUserID(orders)
for i, k := range keys {
results[i] = &dataloader.Result{Data: orderMap[k.String()]}
}
return results
})
Subscriptions
type Subscription {
orderStatusChanged(orderId: ID!): OrderStatusEvent!
}
type OrderStatusEvent {
orderId: ID!
status: OrderStatus!
updatedAt: DateTime!
}
// TypeScript — Apollo Server with Redis PubSub
import { RedisPubSub } from "graphql-redis-subscriptions";
const pubsub = new RedisPubSub({
publisher: new Redis(process.env.REDIS_URL),
subscriber: new Redis(process.env.REDIS_URL),
});
const resolvers = {
Subscription: {
orderStatusChanged: {
subscribe: (_root, { orderId }) =>
pubsub.asyncIterableIterator(`ORDER_STATUS:${orderId}`),
},
},
Mutation: {
updateOrderStatus: async (_root, { orderId, status }) => {
const order = await db.order.update({ where: { id: orderId }, data: { status } });
await pubsub.publish(`ORDER_STATUS:${orderId}`, { orderStatusChanged: order });
return order;
},
},
};
Security
Query Complexity and Depth Limits
Without limits, a single query can exhaust server resources:
# Depth attack
{ user { friends { friends { friends { friends { id } } } } } }
# Breadth attack — requests thousands of fields
{ users(first: 1000) { orders(first: 1000) { items(first: 1000) { id } } } }
// TypeScript — graphql-depth-limit + graphql-query-complexity
import depthLimit from "graphql-depth-limit";
import { createComplexityLimitRule } from "graphql-query-complexity";
const server = new ApolloServer({
validationRules: [
depthLimit(5),
createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 2,
listFactor: 10,
}),
],
});
Disable Introspection in Production
const server = new ApolloServer({
introspection: process.env.NODE_ENV !== "production",
});
Field-Level Authorization
# Python — Strawberry permission classes
import strawberry
from strawberry.permission import BasePermission
class IsAuthenticated(BasePermission):
message = "Not authenticated"
def has_permission(self, source, info, **kwargs) -> bool:
return info.context.user is not None
class IsAdmin(BasePermission):
message = "Admin access required"
def has_permission(self, source, info, **kwargs) -> bool:
return getattr(info.context.user, "role", None) == "admin"
@strawberry.type
class Query:
@strawberry.field(permission_classes=[IsAuthenticated])
def me(self, info) -> User:
return info.context.user
@strawberry.field(permission_classes=[IsAdmin])
def all_users(self, info) -> list[User]:
return User.objects.all()
Schema Evolution
| Change | Safe? | Notes |
|---|---|---|
| Add a field | Yes | Existing clients ignore unknown fields |
| Add a type | Yes | Not exposed until a query uses it |
| Add a non-null argument | No | Breaks clients not passing the argument |
| Add an optional argument | Yes | Default value required |
| Remove or rename a field | No | Deprecate first, remove after migration |
| Change field type | No | Always breaking |
# Deprecate before removing — give clients time to migrate
type User {
name: String @deprecated(reason: "Use `firstName` and `lastName` instead")
firstName: String!
lastName: String!
}
Red Flags
- Returning raw errors in the
errorsarray for business failures — use mutation payload types with a typederrorsfield; the top-levelerrorsarray is for server errors only. - No DataLoader for nested list resolvers — every list field that loads related data without batching causes N+1 queries; instrument with query logging to catch them.
- Introspection enabled in production — exposes your full schema to attackers; disable it or restrict to authenticated users.
- No depth or complexity limits — a deeply nested query can exhaust CPU and memory; always set limits in validation rules.
- Nullable everything — excessive nullability forces clients to null-check every field; use
!for fields that are always present. - Business logic in resolvers — resolvers become untestable and duplicated; keep resolvers thin and delegate to a service layer.
- Sharing DataLoader instances across requests — DataLoaders cache per-request; a shared loader leaks data between users.
- One mutation per field —
updateUserName,updateUserEmailas separate mutations is a smell; useupdateUser(input: UpdateUserInput!)with partial input.
Checklist
- All list fields use Cursor Connection pagination — no offset-based
skip/limit - Mutations return dedicated payload types with a typed
errorsfield - DataLoader used for every resolver that loads related entities — no N+1 queries
- Query depth limit set (max 5–7 levels)
- Query complexity limit set and tuned to realistic usage
- Introspection disabled in production
- Field-level authorization applied — not just route-level auth middleware
- Non-null (
!) used for fields that are always present — nullable only where absence is meaningful - Deprecated fields annotated with
@deprecated(reason: "...")before removal - DataLoader instances created per-request — never shared across requests
- Subscriptions use a pub/sub backend (Redis) — not in-memory for multi-instance deployments
- Schema linted with
graphql-inspectoror equivalent in CI