GraphQL
A query language for APIs and a runtime for executing those queries. Clients request exactly the data they need; the schema is the contract.
A query language for APIs and a runtime for executing those queries. Clients request exactly the data they need; the schema is the contract.
Core Concepts
Schema-first:
- Type definitions are the source of truth
- Strongly typed: every field has a declared return type
- Introspectable: clients can query the schema itself
Query types:
- Query — read-only data fetching
- Mutation — write operations
- Subscription — real-time event streams
Resolvers:
- A function that populates a single field
- Resolver chain: Query.products → Product.category → Category.name
- Each level resolved independently — enables N+1 if not careful
Schema Definition Language
# schema.graphql
scalar DateTime
scalar JSON
enum OrderStatus {
PENDING
PROCESSING
SHIPPED
DELIVERED
CANCELLED
}
type Product {
id: ID!
name: String!
price: Float!
category: Category!
inStock: Boolean!
createdAt: DateTime!
tags: [String!]!
}
type Category {
id: ID!
name: String!
products(limit: Int = 20, offset: Int = 0): [Product!]!
}
type Order {
id: ID!
status: OrderStatus!
items: [OrderItem!]!
total: Float!
createdAt: DateTime!
}
type OrderItem {
product: Product!
quantity: Int!
unitPrice: Float!
}
type Query {
product(id: ID!): Product
products(category: String, inStock: Boolean, limit: Int): [Product!]!
order(id: ID!): Order
myOrders: [Order!]!
}
type Mutation {
createProduct(input: CreateProductInput!): Product!
updateProduct(id: ID!, input: UpdateProductInput!): Product!
deleteProduct(id: ID!): Boolean!
placeOrder(input: PlaceOrderInput!): Order!
}
type Subscription {
orderStatusChanged(orderId: ID!): Order!
}
input CreateProductInput {
name: String!
price: Float!
categoryId: ID!
tags: [String!]
}
input UpdateProductInput {
name: String
price: Float
inStock: Boolean
}
input PlaceOrderInput {
items: [OrderItemInput!]!
paymentMethodId: ID!
}
input OrderItemInput {
productId: ID!
quantity: Int!
}
Python — Strawberry GraphQL
# app/graphql/schema.py
import strawberry
from strawberry.types import Info
from typing import List, Optional
from app.db import get_db
from app.models import Product as DBProduct
@strawberry.type
class Product:
id: strawberry.ID
name: str
price: float
in_stock: bool
@strawberry.type
class Query:
@strawberry.field
async def products(
self,
info: Info,
category: Optional[str] = None,
limit: int = 20,
) -> List[Product]:
db = info.context["db"]
query = db.query(DBProduct)
if category:
query = query.filter(DBProduct.category == category)
results = query.limit(limit).all()
return [Product(id=p.id, name=p.name, price=p.price, in_stock=p.in_stock)
for p in results]
@strawberry.type
class Mutation:
@strawberry.mutation
async def create_product(
self, info: Info, name: str, price: float, category_id: strawberry.ID
) -> Product:
db = info.context["db"]
db_product = DBProduct(name=name, price=price, category_id=category_id)
db.add(db_product)
db.commit()
return Product(id=db_product.id, name=name, price=price, in_stock=True)
schema = strawberry.Schema(query=Query, mutation=Mutation)
# FastAPI integration
from strawberry.fastapi import GraphQLRouter
graphql_app = GraphQLRouter(schema, context_getter=get_context)
app.include_router(graphql_app, prefix="/graphql")DataLoader — Solving N+1
# Without DataLoader: 1 query per product's category = N+1
# With DataLoader: 1 batched query for all categories
from strawberry.dataloader import DataLoader
from collections import defaultdict
async def load_categories(keys: list[str]) -> list[Category]:
"""Called once with ALL category IDs needed in a single request."""
db = get_db()
categories = db.query(DBCategory).filter(DBCategory.id.in_(keys)).all()
category_map = {str(c.id): c for c in categories}
return [category_map.get(key) for key in keys]
# In context factory
def get_context() -> dict:
return {
"db": get_db(),
"category_loader": DataLoader(load_fn=load_categories),
}
# In Product resolver
@strawberry.type
class Product:
id: strawberry.ID
@strawberry.field
async def category(self, info: Info) -> Category:
return await info.context["category_loader"].load(self.category_id)Subscriptions — WebSocket
import strawberry
from strawberry.subscriptions import GRAPHQL_WS_PROTOCOL
import asyncio
@strawberry.type
class Subscription:
@strawberry.subscription
async def order_status_changed(self, order_id: strawberry.ID) -> AsyncGenerator[Order, None]:
# Poll DB or subscribe to Redis pub/sub
async for event in redis_subscribe(f"order:{order_id}:status"):
order = await get_order(order_id)
yield order
schema = strawberry.Schema(
query=Query,
mutation=Mutation,
subscription=Subscription,
)
# FastAPI with WebSocket support
graphql_app = GraphQLRouter(
schema,
subscription_protocols=[GRAPHQL_WS_PROTOCOL],
)Federation — Multi-Service Graph
# products-service schema
type Product @key(fields: "id") {
id: ID!
name: String!
price: Float!
}
# orders-service schema — extends Product from products-service
extend type Product @key(fields: "id") {
id: ID! @external
orderHistory: [Order!]!
}
Query Depth and Complexity Limiting
from strawberry.extensions import QueryDepthLimiter
from graphql import GraphQLError
schema = strawberry.Schema(
query=Query,
extensions=[
QueryDepthLimiter(max_depth=10), # prevent deeply nested queries
]
)Common Failure Cases
N+1 query problem causing database overload
Why: resolving a list of N items triggers a separate DB query for each item's related field (e.g., each Product fetches its Category individually).
Detect: EXPLAIN ANALYZE or query logs show hundreds of near-identical single-row queries per request; response latency scales linearly with list size.
Fix: wrap every resolver that loads a related entity in a DataLoader so all keys in a request batch into one query.
Deeply nested query causes exponential database load
Why: GraphQL lets clients request arbitrarily nested data (e.g., users → orders → products → category → products → ...); a malicious or naive query can trigger exponential resolver calls.
Detect: a single query brings down the DB; EXPLAIN shows query count or join depth explodes.
Fix: add QueryDepthLimiter (max 6–10 levels) and a complexity budget; reject queries that exceed the threshold before any resolver runs.
Mutation returning stale data due to missing re-fetch Why: a mutation updates a record and returns the pre-update object from the resolver; the client caches the stale response and displays wrong data. Detect: the UI shows the old value immediately after a successful mutation; a hard refresh shows the correct value. Fix: in the mutation resolver, re-fetch the record from the DB after the write and return the fresh version, not the input data.
Over-fetching via schema introspection in production
Why: introspection is enabled by default; attackers use it to enumerate every type, field, and resolver in the schema, enabling targeted injection or business logic attacks.
Detect: access logs show __schema queries from unexpected clients.
Fix: disable introspection in production (schema = strawberry.Schema(..., introspection=False)) and expose it only in staging/dev environments.
Connections
se-hub · cs-fundamentals/grpc · cs-fundamentals/api-design · cs-fundamentals/microservices-patterns · technical-qa/graphql-testing · web-frameworks/fastapi
Open Questions
- What are the most common misapplications of this concept in production codebases?
- When should you explicitly choose not to use this pattern or technique?
Related reading