Skip to content
LowLevelDesign Mastery

GraphQL Fundamentals

Ask for exactly what you need, get exactly that

GraphQL is a query language and runtime for APIs developed by Facebook (now Meta) in 2012 and open-sourced in 2015. Unlike REST APIs that return fixed data structures, GraphQL allows clients to specify exactly what data they need, reducing over-fetching and under-fetching.

Traditional REST APIs have a fundamental limitation: they return fixed data structures. If you need user data, you call /users/123 and get all user fields—even if you only need the name. If you need related data (like a user’s orders), you make multiple requests. This leads to:

  • Over-fetching: Getting data you don’t need (wasting bandwidth)
  • Under-fetching: Not getting enough data (requiring multiple requests)
  • Multiple round trips: Slower performance due to network latency

GraphQL solves this by providing a single endpoint where clients can request exactly the data they need, including related data, in a single request.

REST GET /users/123 over-fetching the full user object versus a GraphQL query returning only the requested name field
  1. Single Endpoint - One URL for all operations
  2. Client-Specified Queries - Client decides what data to fetch
  3. Strongly Typed Schema - Schema defines all possible queries
  4. Introspection - Schema is self-documenting
  5. Real-time Subscriptions - WebSocket support for live updates

Client making three REST requests for user, posts and friends versus one GraphQL query fetching all of them from a single endpoint
GET /users/123 → Returns full user object
GET /users/123/orders → Returns all orders
GET /users/123/profile → Returns full profile

Problems:

  • Over-fetching (get data you don’t need) - Mobile app might only need name, but gets full user object with 20+ fields
  • Under-fetching (need multiple requests) - Need user and orders? That’s 2+ requests
  • Multiple round trips - Each request adds network latency, slowing down the app

GraphQL: Single Endpoint, Flexible Queries

Section titled “GraphQL: Single Endpoint, Flexible Queries”
query {
user(id: 123) {
name
email
orders {
id
total
}
}
}

Benefits:

  • Fetch exactly what you need - Request only the fields you need, nothing more
  • Get related data in one request - Fetch user and their orders in a single query
  • Single round trip - One network request instead of multiple, reducing latency

GraphQL schema defining a User type, exposing user and users queries, and allowing a createUser mutation

Schema defines what data is available and how to query it.

type User {
id: ID!
name: String!
email: String!
orders: [Order!]!
}
type Order {
id: ID!
total: Float!
items: [OrderItem!]!
}
type Query {
user(id: ID!): User
users: [User!]!
}
type Mutation {
createUser(name: String!, email: String!): User!
updateUser(id: ID!, name: String): User!
}
TypeMeaningExample
StringText"John Doe"
IntInteger42
FloatDecimal99.99
BooleanTrue/Falsetrue
IDUnique identifier"123"
!Required (non-null)String!
[Type]Array[String!]!

GraphQL query flow from client query through user and posts resolvers fetching from the database and returning combined JSON

Queries are for reading data. They’re like GET requests in REST.

query {
user(id: "123") {
name
email
}
}

Response:

{
"data": {
"user": {
"name": "John Doe",
"email": "[email protected]"
}
}
}
query {
users(limit: 10, offset: 0) {
id
name
}
}
query {
user(id: "123") {
name
email
orders {
id
total
items {
product {
name
price
}
quantity
}
}
}
}

This single query replaces multiple REST calls:

  • GET /users/123
  • GET /users/123/orders
  • GET /orders/456/items
  • GET /products/789

Get multiple versions of same field:

query {
user1: user(id: "123") {
name
}
user2: user(id: "456") {
name
}
}

Reusable field sets:

fragment UserInfo on User {
id
name
email
}
query {
user(id: "123") {
...UserInfo
orders {
id
}
}
}

GraphQL createUser mutation flow from client through a mutation resolver that validates input and inserts the user into the database

Mutations are for creating, updating, or deleting data. Like POST/PUT/DELETE in REST.

mutation {
createUser(name: "Jane Doe", email: "[email protected]") {
id
name
email
}
}

Response:

{
"data": {
"createUser": {
"id": "456",
"name": "Jane Doe",
"email": "[email protected]"
}
}
}
mutation {
updateUser(id: "123", name: "John Smith") {
id
name
email
}
}
mutation {
deleteUser(id: "123") {
id
}
}

Execute multiple mutations in one request:

mutation {
createUser(name: "Alice", email: "[email protected]") {
id
}
createOrder(userId: "123", items: [...]) {
id
}
}

GraphQL subscription to userUpdated over a persistent WebSocket connection, with the server pushing updates when data changes

Subscriptions provide real-time data using WebSockets.

subscription {
userUpdated(userId: "123") {
id
name
email
}
}

How it works:

  1. Client subscribes via WebSocket
  2. Server sends updates when data changes
  3. Client receives real-time updates
  • Live chat messages
  • Real-time notifications
  • Stock price updates
  • Collaborative editing
  • Live dashboards

GraphQL N+1 problem where fetching 100 users with posts takes 101 queries, fixed by DataLoader batching into 2 queries

The biggest performance issue in GraphQL.

query {
users {
name
orders { # N+1 problem!
id
total
}
}
}

What happens:

  1. Query 1: SELECT * FROM users (gets 100 users)
  2. Query 2: SELECT * FROM orders WHERE user_id = 1
  3. Query 3: SELECT * FROM orders WHERE user_id = 2
  4. … (100 more queries!)

Total: 1 + 100 = 101 queries! This is the N+1 query problem—one query to get the list, then N queries (one per item) to get related data.

DataLoader batches requests:

Without DataLoader, 101 queries fetch orders per user; with DataLoader, one batched user_id IN query cuts it to 2 queries

How DataLoader works:

  1. Collects all requests in a batch
  2. Waits for next event loop tick
  3. Executes single batched query
  4. Distributes results to individual requests
💡 Tip: Click dropdown to switch between languages
"dataloader_example.py
from dataloader import DataLoader
import asyncio
# Create DataLoader for orders
order_loader = DataLoader(
batch_load_fn=lambda user_ids: load_orders_for_users(user_ids)
)
async def get_user_with_orders(user_id):
user = await get_user(user_id)
# This will be batched!
orders = await order_loader.load(user_id)
return {
"user": user,
"orders": orders
}
async def load_orders_for_users(user_ids):
# Single query for all users
orders = await db.query(
"SELECT * FROM orders WHERE user_id IN ?",
user_ids
)
# Group by user_id
orders_by_user = {}
for order in orders:
if order.user_id not in orders_by_user:
orders_by_user[order.user_id] = []
orders_by_user[order.user_id].append(order)
# Return in same order as requested
return [orders_by_user.get(uid, []) for uid in user_ids]

GraphQL query calling field resolvers like Query.user and User.posts that fetch from user service, post service, database and APIs

Resolvers are functions that fetch data for each field.

💡 Tip: Click dropdown to switch between languages
"resolvers.py
from ariadne import QueryType, MutationType
from typing import Optional, List
query = QueryType()
mutation = MutationType()
@query.field("user")
def resolve_user(_, info, id: str) -> Optional[dict]:
"""Resolver for user query"""
return user_repository.find_by_id(id)
@query.field("users")
def resolve_users(_, info) -> List[dict]:
"""Resolver for users query"""
return user_repository.find_all()
@mutation.field("createUser")
def resolve_create_user(_, info, name: str, email: str) -> dict:
"""Resolver for createUser mutation"""
user = user_repository.create(name=name, email=email)
return user
# Field resolvers (for nested fields)
def resolve_user_orders(user: dict, info) -> List[dict]:
"""Resolver for User.orders field"""
return order_repository.find_by_user_id(user["id"])

Decision factors for choosing GraphQL for complex, multi-client or real-time needs versus REST for simple CRUD and HTTP caching
  • Multiple clients with different data needs - Web app needs all fields, mobile app needs minimal fields
  • Mobile apps where bandwidth matters - Reducing data transfer saves battery and improves performance
  • Complex relationships between data - Easier to fetch related data in one query
  • Rapidly evolving API requirements - Schema changes don’t break existing queries (backward compatible)
  • Real-time updates needed (subscriptions) - Built-in WebSocket support for live data
  • Simple CRUD operations - REST is simpler for basic create/read/update/delete
  • Caching is critical (HTTP caching) - REST benefits from HTTP caching infrastructure
  • File uploads (GraphQL handles this poorly) - REST is better suited for file operations
  • Simple APIs where over-fetching isn’t a problem - If bandwidth isn’t a concern, REST is simpler
  • Existing REST infrastructure - If you already have REST APIs, migration might not be worth it

GraphQL best practices split into pagination, security with depth limits and rate limiting, and performance with DataLoader and caching

Bad:

query {
users { # Could return millions!
id
name
}
}

Why it’s bad: Without pagination, a query could return millions of records, causing performance issues, memory problems, and timeouts.

Good:

query {
users(first: 10, after: "cursor123") {
edges {
node {
id
name
}
}
pageInfo {
hasNextPage
endCursor
}
}
}

Prevent deeply nested queries:

MAX_QUERY_DEPTH = 10
def validate_query_depth(query, max_depth=MAX_QUERY_DEPTH):
depth = calculate_depth(query)
if depth > max_depth:
raise GraphQLError("Query too deep")

Authorize at field level:

@query.field("user")
def resolve_user(_, info, id: str):
user = user_repository.find_by_id(id)
# Check if user can access email field
if not can_access_field(info, "email"):
user.pop("email") # Remove email from response
return user

Prevent expensive queries:

def calculate_complexity(query):
complexity = 0
for field in query.fields:
complexity += field.complexity
if field.has_list:
complexity *= field.list_size
return complexity
if calculate_complexity(query) > MAX_COMPLEXITY:
raise GraphQLError("Query too complex")

Disable introspection in production (or limit it):

# Disable introspection
schema = make_executable_schema(type_defs, resolvers)
schema.introspection = False # In production

GraphQL implementation layers from request to schema validation, resolvers, DataLoader batching and repository database access

At the code level, GraphQL translates to resolvers, schema definitions, and DataLoader patterns.

💡 Tip: Click dropdown to switch between languages
"graphql_service.py
from ariadne import make_executable_schema, QueryType, MutationType
from dataloader import DataLoader
# Schema definition
type_defs = """
type User {
id: ID!
name: String!
email: String!
orders: [Order!]!
}
type Order {
id: ID!
total: Float!
items: [OrderItem!]!
}
type Query {
user(id: ID!): User
users: [User!]!
}
type Mutation {
createUser(name: String!, email: String!): User!
}
"""
query = QueryType()
mutation = MutationType()
# DataLoaders
order_loader = DataLoader(
batch_load_fn=lambda user_ids: load_orders_batch(user_ids)
)
@query.field("user")
def resolve_user(_, info, id: str):
return user_repository.find_by_id(id)
@query.field("users")
def resolve_users(_, info):
return user_repository.find_all()
@mutation.field("createUser")
def resolve_create_user(_, info, name: str, email: str):
return user_repository.create(name=name, email=email)
# Field resolver with DataLoader
def resolve_user_orders(user, info):
return order_loader.load(user["id"])
schema = make_executable_schema(type_defs, [query, mutation])

Ask for What You Need

GraphQL lets clients specify exactly what data they need, reducing over-fetching and under-fetching.

Watch for N+1

The N+1 query problem is GraphQL’s biggest performance issue. Use DataLoader to batch requests.

Resolvers Fetch Data

Resolvers are functions that fetch data for each field. They’re where your business logic lives.

Schema is Contract

GraphQL schema defines your API contract. It’s self-documenting and strongly typed.