GraphQL Fundamentals
Query exactly what you need with GraphQL - an alternative to REST APIs
Query Exactly What You Need
REST APIs have limitations. Over-fetching (getting data you don't need), under-fetching (making multiple requests to get related data), and versioning headaches plague traditional REST APIs. GraphQL solves these problems.
GraphQL is a query language. Instead of hitting multiple endpoints, clients send a single query describing exactly what data they need. Want user name but not email? User posts but not comments? Just specify it in the query. The server returns precisely what you asked for, nothing more, nothing less.
In this lesson, you'll understand when GraphQL beats REST, design GraphQL schemas with types and fields, implement queries and mutations with Strawberry (Python), solve the N+1 problem with DataLoaders, add authentication, and follow production patterns from GitHub, Shopify, and Facebook.
Lesson Sections
GraphQL vs REST: When to Use Each
REST and GraphQL solve different problems. REST is simple and predictable. GraphQL is flexible and efficient. Understanding when to use each prevents over-engineering and ensures you pick the right tool for your use case.
REST API: Multiple Endpoints, Fixed Responses
REST APIs have fixed endpoints that return predetermined data structures. Let's explore the challenges this creates.
Basic REST endpoint:
# Get user
GET /api/users/123
# Response: Fixed structure with ALL fields
{
"id": 123,
"name": "Alice",
"email": "alice@example.com",
"bio": "Software engineer",
"avatar": "https://...",
"follower_count": 1523,
"following_count": 342,
"created_at": "2020-01-15",
"last_login": "2024-01-15T10:30:00Z"
}Problem 1: Over-Fetching
By default, REST endpoints return all fields even when you only need a few. A mobile app that only needs the user's name and avatar still receives all 9 fields, wasting bandwidth.
# Mobile app only needs:
{
"name": "Alice",
"avatar": "https://..."
}
# But basic REST returns everything:
GET /api/users/123
{
"id": 123,
"name": "Alice",
"email": "alice@example.com", # ❌ Not needed
"bio": "Software engineer", # ❌ Not needed
"avatar": "https://...",
"follower_count": 1523, # ❌ Not needed
"following_count": 342, # ❌ Not needed
"created_at": "2020-01-15", # ❌ Not needed
"last_login": "2024-01-15T10:30:00Z" # ❌ Not needed
}
# Wasted: 7 out of 9 fields (78% wasted bandwidth)REST Solution: Sparse Fieldsets / Field Selection
REST APIs can support field selection through query parameters, allowing clients to request only specific fields:
# Request only specific fields
GET /api/users/123?fields=name,avatar
# Response:
{
"name": "Alice",
"avatar": "https://..."
}
# Or using JSON API specification format
GET /api/users/123?fields[user]=name,avatarTrade-offs with REST field selection:
- Not standardized: Each API implements it differently (fields=, select=, include=)
- Backend complexity: Requires custom filtering logic for each endpoint
- Optional feature: Not all REST APIs support it; must check documentation
- Flat structure: Doesn't help with nested relationships (posts.comments.author)
- Documentation burden: Must document which fields are available for filtering
GraphQL's advantage: Field selection is built-in, standardized, and works automatically for all queries including deeply nested relationships, no custom backend code needed.
Problem 2: Under-Fetching
REST endpoints return limited related data. To display a user profile page, you need multiple requests to different endpoints.
# User profile page needs:
# Request 1: User basic info
GET /api/users/123
# Request 2: User's posts
GET /api/users/123/posts
Response: {
"posts": [
{"id": 1, "title": "Post 1", "body": "...", "created_at": "..."},
{"id": 2, "title": "Post 2", "body": "...", "created_at": "..."}
]
}
# Request 3: User's followers
GET /api/users/123/followers
# Request 4: User's following
GET /api/users/123/following
# Already 4 requests just for basic profile!
# Need comments too? Add more requests...Problem 3: N+1 Query Problem
When fetching lists of items with relationships, you often need one request per item to get related data. This multiplies your HTTP requests dramatically.
# Request 1: Get post comments
GET /api/posts/1/comments
Response: {
"comments": [
{"id": 1, "author_id": 456, "text": "Great post!"},
{"id": 2, "author_id": 789, "text": "Thanks!"},
{"id": 3, "author_id": 321, "text": "Awesome!"}
]
}
# Problem: Comments only have author_id, not author details
# Need to fetch each author separately:
# Request 2: Get author for comment 1
GET /api/users/456
# Request 3: Get author for comment 2
GET /api/users/789
# Request 4: Get author for comment 3
GET /api/users/321
# Total: 1 request for comments + N requests for authors = N+1 problem
# With 10 comments → 11 requests!
# With 100 comments → 101 requests!Total Requests for a User Profile Page
- 1 request: User basic info
- 1 request: User's posts
- 1 request: User's followers
- 1 request: User's following
- 5 requests: Comments on latest 5 posts
- = 9 HTTP requests + lots of over-fetched data
GraphQL: Single Endpoint, Flexible Queries
# GraphQL: Single endpoint, client specifies exact data needed
# Query everything for user profile in ONE request
POST /graphql
{
user(id: 123) {
name
avatar
posts(limit: 5) {
title
createdAt
comments {
text
author {
name
avatar
}
}
}
followerCount
}
}
# Response: Exactly what was requested, nothing more
{
"data": {
"user": {
"name": "Alice",
"avatar": "https://...",
"posts": [
{
"title": "Post 1",
"createdAt": "2024-01-15",
"comments": [
{
"text": "Great post!",
"author": {
"name": "Bob",
"avatar": "https://..."
}
}
]
}
],
"followerCount": 1523
}
}
}
# Benefits:
# ✓ 1 request instead of 9
# ✓ No over-fetching (didn't include email, bio, etc.)
# ✓ No under-fetching (got user, posts, comments, authors in one query)
# ✓ Nested relationships resolved automatically
# Mobile app needs less data? Just request less:
{
user(id: 123) {
name
avatar
}
}
# Response:
{
"data": {
"user": {
"name": "Alice",
"avatar": "https://..."
}
}
}
# Same endpoint, different query, different response
# No need for separate /api/users/simplified endpointPerformance Comparison: User Profile Page
| Metric | REST | GraphQL |
|---|---|---|
| HTTP Requests | 9 requests | 1 request |
| Data Transferred | ~45 KB (lots of unused fields) | ~12 KB (only requested fields) |
| Latency | 450ms (9 × 50ms per request) | 50ms (1 request) |
| Result | 9× more requests | 9× faster, 73% less data |
When to Use GraphQL vs REST
Use GraphQL When
- Complex, nested data relationships
- Multiple clients (web, mobile, desktop) need different data
- Mobile apps (minimize bandwidth)
- Rapid frontend iteration (no backend changes needed)
- Aggregating data from multiple sources
- Real-time subscriptions needed
- Examples: Social networks, e-commerce, dashboards
Use REST When
- Simple CRUD operations
- Public APIs (easier for third-party developers)
- File uploads/downloads
- HTTP caching important (CDN, browser cache)
- Team unfamiliar with GraphQL
- Microservices (simple contracts)
- Examples: Webhooks, payment APIs, CRUD apps
You Can Use Both
Many companies use both REST and GraphQL. GitHub has REST APIs for simple operations and GraphQL for complex queries. Shopify uses REST for webhooks and GraphQL for storefront data. Don't force GraphQL everywhere, use it where flexibility matters, REST where simplicity wins.
GraphQL Schema and Type System
GraphQL is strongly typed. Every field has a type, and the schema defines what queries are possible. This provides automatic validation, introspection, and excellent developer experience with auto-complete in GraphQL editors.
GraphQL Schema Definition Language (SDL)
# GraphQL Schema Definition Language
# Scalar types (built-in primitives)
# - Int: 32-bit integer
# - Float: floating point number
# - String: UTF-8 string
# - Boolean: true or false
# - ID: unique identifier (string, but semantically an ID)
# Object type with fields
type User {
id: ID! # ! means required (non-nullable)
name: String! # Required string
email: String # Optional string (can be null)
age: Int
isActive: Boolean!
posts: [Post!]! # Required list of required Posts
followerCount: Int!
}
# Another object type
type Post {
id: ID!
title: String!
body: String!
author: User! # Reference to User type
comments: [Comment!]!
publishedAt: String # ISO 8601 timestamp
}
# Nested type
type Comment {
id: ID!
text: String!
author: User!
post: Post!
createdAt: String!
}
# Query type (entry point for reads)
type Query {
# Get single user by ID
user(id: ID!): User
# Get all users
users: [User!]!
# Get user's posts
userPosts(userId: ID!, limit: Int = 10): [Post!]!
# Search posts
searchPosts(query: String!): [Post!]!
}
# Mutation type (entry point for writes)
type Mutation {
# Create user
createUser(name: String!, email: String!): User!
# Update user
updateUser(id: ID!, name: String, email: String): User!
# Delete user
deleteUser(id: ID!): Boolean!
# Create post
createPost(userId: ID!, title: String!, body: String!): Post!
}
# Custom scalar for DateTime
scalar DateTime
# Enum type
enum UserRole {
ADMIN
MODERATOR
USER
GUEST
}
# Input type (for mutations)
input CreateUserInput {
name: String!
email: String!
age: Int
role: UserRole = USER # Default value
}
# Interface (shared fields)
interface Node {
id: ID!
createdAt: DateTime!
}
# Union type (can be one of several types)
union SearchResult = User | Post | CommentType System Rules
- ! (exclamation mark): Required field, cannot be null
- [Type]: List (array) of Type, can be null
- [Type!]: List can be null, but items cannot be null
- [Type]!: List cannot be null, but items can be null
- [Type!]!: Neither list nor items can be null (most common)
- Default values:
limit: Int = 10provides fallback
Schema Example: Blog API
# Complete schema for a blog API
type User {
id: ID!
username: String!
email: String!
posts: [Post!]!
comments: [Comment!]!
}
type Post {
id: ID!
title: String!
body: String!
author: User!
comments: [Comment!]!
tags: [String!]!
publishedAt: String
updatedAt: String!
}
type Comment {
id: ID!
text: String!
author: User!
post: Post!
createdAt: String!
}
type Query {
# Users
user(id: ID!): User
users(limit: Int = 10, offset: Int = 0): [User!]!
# Posts
post(id: ID!): Post
posts(limit: Int = 10, offset: Int = 0): [Post!]!
postsByUser(userId: ID!): [Post!]!
# Search
searchPosts(query: String!): [Post!]!
}
type Mutation {
# User mutations
createUser(username: String!, email: String!, password: String!): User!
updateUser(id: ID!, username: String, email: String): User!
# Post mutations
createPost(title: String!, body: String!, tags: [String!]): Post!
updatePost(id: ID!, title: String, body: String): Post!
deletePost(id: ID!): Boolean!
publishPost(id: ID!): Post!
# Comment mutations
createComment(postId: ID!, text: String!): Comment!
deleteComment(id: ID!): Boolean!
}
# Schema defines the contract between client and server
# Client knows exactly what's available (introspection)
# Server validates all queries against schema automaticallyQueries and Resolvers
Queries fetch data. Each field in the schema has a resolver, a function that returns the data for that field. Resolvers can fetch from databases, call other APIs, or compute values.
Writing GraphQL Queries
# Basic query - get single user
query GetUser {
user(id: "123") {
name
email
}
}
# Response:
{
"data": {
"user": {
"name": "Alice",
"email": "alice@example.com"
}
}
}
# Query with nested fields
query GetUserWithPosts {
user(id: "123") {
name
posts {
title
publishedAt
}
}
}
# Response:
{
"data": {
"user": {
"name": "Alice",
"posts": [
{
"title": "GraphQL Tutorial",
"publishedAt": "2024-01-15"
},
{
"title": "Python Best Practices",
"publishedAt": "2024-01-10"
}
]
}
}
}
# Query with arguments
query GetRecentPosts {
posts(limit: 5, offset: 0) {
title
author {
name
}
}
}
# Multiple queries in one request (parallel execution)
query GetDashboardData {
currentUser: user(id: "123") {
name
email
}
recentPosts: posts(limit: 5) {
title
author {
name
}
}
stats: userStats(userId: "123") {
postCount
commentCount
}
}
# Query with variables (for dynamic queries)
query GetUser($userId: ID!) {
user(id: $userId) {
name
email
}
}
# Variables (sent separately):
{
"userId": "123"
}
# Fragments (reusable field sets)
query GetUsersWithPostInfo {
user1: user(id: "123") {
...UserFields
}
user2: user(id: "456") {
...UserFields
}
}
fragment UserFields on User {
name
email
posts {
title
publishedAt
}
}Implementing Resolvers
Resolvers are functions that return data for each field. They receive the parent object, arguments, context, and info. Let's see how to implement them.
# Resolver anatomy
def resolve_user(parent, info, id):
"""
Resolver for user(id: ID!): User
Args:
parent: Parent object (None for Query root)
info: GraphQL execution info (context, etc.)
id: Query argument
Returns:
User object or None
"""
# Fetch from database
user = database.get_user(id)
return user
def resolve_user_posts(parent, info, limit=10):
"""
Resolver for User.posts field
Args:
parent: User object (parent field)
info: Execution info
limit: Argument with default
Returns:
List of Post objects
"""
# parent is the User object
user_id = parent.id
# Fetch user's posts
posts = database.get_posts_by_user(user_id, limit=limit)
return posts
def resolve_post_author(parent, info):
"""
Resolver for Post.author field
Args:
parent: Post object
info: Execution info
Returns:
User object
"""
# parent is the Post object
author_id = parent.author_id
# Fetch author
author = database.get_user(author_id)
return author
# Resolver execution flow for this query:
# query {
# user(id: "123") { # 1. resolve_user() called
# name # 2. Return user.name (no resolver, direct field access)
# posts { # 3. resolve_user_posts(parent=user) called
# title # 4. Return post.title for each post
# author { # 5. resolve_post_author(parent=post) called for each
# name # 6. Return author.name
# }
# }
# }
# }
# GraphQL executes resolvers as needed to build response treeResolver Execution Order
Query: user(id: "123") { name, posts { title } }
- Step 1: Execute
resolve_user("123")→ Returns User object - Step 2: Access
user.name→ "Alice" (direct field, no resolver) - Step 3: Execute
resolve_user_posts(parent=user)→ Returns [Post1, Post2] - Step 4: For each post, access
post.title→ ["Title 1", "Title 2"] - Result: Build response tree from resolver results
Mutations: Create, Update, Delete
Mutations modify data. While queries are read-only, mutations create, update, or delete data. Mutations return the modified object so clients can update their cache immediately.
Writing Mutations
# Create mutation
mutation CreatePost {
createPost(
title: "GraphQL is Awesome"
body: "GraphQL makes APIs flexible and efficient..."
tags: ["graphql", "api"]
) {
id
title
author {
name
}
publishedAt
}
}
# Response:
{
"data": {
"createPost": {
"id": "789",
"title": "GraphQL is Awesome",
"author": {
"name": "Alice"
},
"publishedAt": "2024-01-15T10:30:00Z"
}
}
}
# Update mutation
mutation UpdatePost {
updatePost(
id: "789"
title: "GraphQL is Amazing"
body: "Updated content..."
) {
id
title
body
updatedAt
}
}
# Delete mutation
mutation DeletePost {
deletePost(id: "789") # Returns Boolean
}
# Response:
{
"data": {
"deletePost": true
}
}
# Mutation with variables (best practice)
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
author {
name
}
}
}
# Variables:
{
"input": {
"title": "New Post",
"body": "Content...",
"tags": ["tutorial"]
}
}
# Multiple mutations (executed sequentially, not parallel!)
mutation CreatePostAndComment {
post: createPost(title: "Title", body: "Body") {
id
}
comment: createComment(postId: "789", text: "Great!") {
id
text
}
}
# Mutations run in order (comment after post)
# Queries run in parallelImplementing Mutation Resolvers
def resolve_create_post(parent, info, title, body, tags=None):
"""
Resolver for createPost mutation
Args:
parent: None (Mutation root)
info: Execution info (includes context with user)
title: Post title
body: Post body
tags: Optional list of tags
Returns:
Created Post object
"""
# Get authenticated user from context
user = info.context.get("user")
if not user:
raise Exception("Authentication required")
# Validate input
if len(title) < 5:
raise Exception("Title must be at least 5 characters")
# Create post in database
post = database.create_post(
title=title,
body=body,
author_id=user.id,
tags=tags or [],
published_at=datetime.now(timezone.utc)
)
# Return created post (GraphQL will resolve nested fields)
return post
def resolve_update_post(parent, info, id, title=None, body=None):
"""
Resolver for updatePost mutation
Args:
parent: None (Mutation root)
info: Execution info
id: Post ID to update
title: Optional new title
body: Optional new body
Returns:
Updated Post object
"""
# Get authenticated user
user = info.context.get("user")
if not user:
raise Exception("Authentication required")
# Fetch post
post = database.get_post(id)
if not post:
raise Exception("Post not found")
# Check authorization (only author can update)
if post.author_id != user.id:
raise Exception("Not authorized to update this post")
# Update fields
update_data = {}
if title is not None:
update_data["title"] = title
if body is not None:
update_data["body"] = body
if update_data:
update_data["updated_at"] = datetime.now(timezone.utc)
post = database.update_post(id, **update_data)
return post
def resolve_delete_post(parent, info, id):
"""
Resolver for deletePost mutation
Args:
parent: None (Mutation root)
info: Execution info
id: Post ID to delete
Returns:
Boolean (True if deleted)
"""
# Get authenticated user
user = info.context.get("user")
if not user:
raise Exception("Authentication required")
# Fetch post
post = database.get_post(id)
if not post:
raise Exception("Post not found")
# Check authorization
if post.author_id != user.id and not user.is_admin:
raise Exception("Not authorized to delete this post")
# Delete post
database.delete_post(id)
return True
# Error handling
# GraphQL returns errors in response:
{
"data": {
"createPost": null
},
"errors": [
{
"message": "Title must be at least 5 characters",
"path": ["createPost"]
}
]
}Mutation Best Practices
- Return the modified object: Not just success/fail, return full object for cache updates
- Use Input types: Group related arguments into input objects
- Validate early: Check permissions and input validity before database operations
- Atomic operations: Use database transactions for multi-step mutations
- Return errors properly: Use GraphQL error format, not exceptions
- Name clearly: createPost, updatePost, deletePost (verb + noun)
Implementing GraphQL with Strawberry
Strawberry is a modern Python GraphQL library with excellent FastAPI integration. It uses Python type hints and dataclasses for a clean, Pythonic API.
Complete GraphQL API with Strawberry
Let's build a complete GraphQL API with Strawberry. First, we'll create a thread-safe in-memory database, then define our GraphQL schema with types, queries, and mutations.
Install Strawberry GraphQL:
pip install strawberry-graphql[fastapi]
Step 1: Create the In-Memory Database
# database.py
import threading
import uuid
from typing import Dict, List, Optional, Any
from datetime import datetime, timezone
class InMemoryDatabase:
"""
A thread-safe In-Memory Database for relational data.
Designed to be dropped into Strawberry GraphQL resolvers.
"""
def __init__(self):
# The 'tables'
self._users: Dict[str, Any] = {}
self._posts: Dict[str, Any] = {}
self._comments: Dict[str, Any] = {}
# Thread safety lock
self._lock = threading.Lock()
# Seed initial data
self._bootstrap()
def _bootstrap(self):
"""Initialize with some starting records."""
u1_id = self.create_user("alice", "alice@example.com")["id"]
u2_id = self.create_user("bob", "bob@example.com")["id"]
p1 = self.create_post(
"GraphQL vs REST",
"A deep dive into API design.",
u1_id,
datetime.now(timezone.utc).isoformat()
)
self.create_comment("Great post!", u2_id, p1["id"])
# --- User Logic ---
def create_user(self, username: str, email: str) -> Dict:
with self._lock:
user_id = f"user_{uuid.uuid4().hex[:8]}"
user = {"id": user_id, "username": username, "email": email}
self._users[user_id] = user
return user
def get_user(self, user_id: str) -> Optional[Dict]:
return self._users.get(user_id)
def get_users(self, limit: int = 10) -> List[Dict]:
return list(self._users.values())[:limit]
# --- Post Logic ---
def create_post(self, title: str, body: str, author_id: str, published_at: str) -> Dict:
with self._lock:
post_id = f"post_{uuid.uuid4().hex[:8]}"
post = {
"id": post_id,
"title": title,
"body": body,
"author_id": author_id,
"published_at": published_at
}
self._posts[post_id] = post
return post
def get_post(self, post_id: str) -> Optional[Dict]:
return self._posts.get(post_id)
def get_posts(self, limit: int = 10, offset: int = 0) -> List[Dict]:
all_posts = list(self._posts.values())
return all_posts[offset: offset + limit]
def get_posts_by_user(self, author_id: str) -> List[Dict]:
return [p for p in self._posts.values() if p["author_id"] == author_id]
def update_post(self, post_id: str, **kwargs) -> Optional[Dict]:
with self._lock:
if post_id in self._posts:
self._posts[post_id].update(kwargs)
return self._posts[post_id]
return None
def delete_post(self, post_id: str) -> bool:
with self._lock:
if post_id in self._posts:
del self._posts[post_id]
return True
return False
# --- Comment Logic ---
def create_comment(self, text: str, author_id: str, post_id: str) -> Dict:
with self._lock:
comment_id = f"comment_{uuid.uuid4().hex[:8]}"
comment = {
"id": comment_id,
"text": text,
"author_id": author_id,
"post_id": post_id
}
self._comments[comment_id] = comment
return comment
def get_comments_by_post(self, post_id: str) -> List[Dict]:
return [c for c in self._comments.values() if c["post_id"] == post_id]
# Initialize database
database = InMemoryDatabase()Database Features
- Thread-safe: Uses threading.Lock() for concurrent access protection
- Auto-bootstrapping: Creates sample users (Alice, Bob), posts, and comments on initialization
- Relational data: Supports users, posts, and comments with foreign key relationships
- Full CRUD: Create, read, update, and delete operations for all entity types
- UUID-based IDs: Generates unique identifiers automatically (user_a1b2c3d4, post_e5f6g7h8)
Step 2: Define GraphQL Types and Resolvers
# schema.py
from fastapi import FastAPI
from strawberry.fastapi import GraphQLRouter
import strawberry
from typing import List, Optional
from datetime import datetime, timezone
from database import database
# Define types using Strawberry decorators
@strawberry.type
class User:
"""User type with fields"""
id: strawberry.ID
username: str
email: str
@strawberry.field
def posts(self) -> List["Post"]:
"""Resolver for posts field"""
posts_data = database.get_posts_by_user(self.id)
return [Post(**p) for p in posts_data]
@strawberry.type
class Post:
"""Post type"""
id: strawberry.ID
title: str
body: str
author_id: strawberry.ID
published_at: Optional[str] = None
@strawberry.field
def author(self) -> User:
"""Resolver for author field"""
user_data = database.get_user(self.author_id)
if user_data:
return User(**user_data)
return None
@strawberry.field
def comments(self) -> List["Comment"]:
"""Resolver for comments field"""
comments_data = database.get_comments_by_post(self.id)
return [Comment(**c) for c in comments_data]
@strawberry.type
class Comment:
"""Comment type"""
id: strawberry.ID
text: str
author_id: strawberry.ID
post_id: strawberry.ID
@strawberry.field
def author(self) -> User:
user_data = database.get_user(self.author_id)
if user_data:
return User(**user_data)
return None
# Define Query type
@strawberry.type
class Query:
"""
Root query type.
Important: Database returns dictionaries, but Strawberry types expect objects.
Use **dict unpacking to convert: User(**user_data)
"""
@strawberry.field
def user(self, id: strawberry.ID) -> Optional[User]:
"""Get user by ID"""
user_data = database.get_user(id)
if user_data:
return User(**user_data) # Convert dict to User object
return None
@strawberry.field
def users(self, limit: int = 10) -> List[User]:
"""Get all users"""
users_data = database.get_users(limit=limit)
return [User(**u) for u in users_data] # Convert each dict to User object
@strawberry.field
def post(self, id: strawberry.ID) -> Optional[Post]:
"""Get post by ID"""
post_data = database.get_post(id)
if post_data:
return Post(**post_data) # Convert dict to Post object
return None
@strawberry.field
def posts(self, limit: int = 10, offset: int = 0) -> List[Post]:
"""Get all posts"""
posts_data = database.get_posts(limit=limit, offset=offset)
return [Post(**p) for p in posts_data] # Convert each dict to Post object
# Define Input types for mutations
@strawberry.input
class CreatePostInput:
title: str
body: str
tags: List[str] = strawberry.field(default_factory=list)
# Define Mutation type
@strawberry.type
class Mutation:
@strawberry.mutation
def create_post(self, input: CreatePostInput, info: strawberry.Info) -> Post:
"""Create new post"""
# Get user from context
user = info.context.get("user")
if not user:
raise Exception("Authentication required")
# Create post
post_data = database.create_post(
title=input.title,
body=input.body,
author_id=user.id,
published_at=datetime.now(timezone.utc).isoformat()
)
return Post(**post_data)
@strawberry.mutation
def update_post(
self,
id: strawberry.ID,
title: Optional[str] = None,
body: Optional[str] = None,
info: strawberry.Info = None
) -> Post:
"""Update post"""
user = info.context.get("user")
if not user:
raise Exception("Authentication required")
post_data = database.get_post(id)
if not post_data:
raise Exception("Post not found")
if post_data["author_id"] != user.id:
raise Exception("Not authorized")
update_data = {}
if title:
update_data["title"] = title
if body:
update_data["body"] = body
updated_post = database.update_post(id, **update_data)
return Post(**updated_post)
@strawberry.mutation
def delete_post(self, id: strawberry.ID, info: strawberry.Info) -> bool:
"""Delete post"""
user = info.context.get("user")
if not user:
raise Exception("Authentication required")
post_data = database.get_post(id)
if not post_data:
raise Exception("Post not found")
if post_data["author_id"] != user.id:
raise Exception("Not authorized")
database.delete_post(id)
return True
# Create schema
schema = strawberry.Schema(query=Query, mutation=Mutation)
# Create FastAPI app
app = FastAPI()
# Add GraphQL router
graphql_app = GraphQLRouter(schema)
app.include_router(graphql_app, prefix="/graphql")Important: Converting Dictionaries to Strawberry Objects
The InMemoryDatabase returns Python dictionaries, but Strawberry types expect objects with attributes. We use **dict unpacking to convert:
# Database returns dict
user_data = database.get_user(id) # {"id": "123", "username": "alice", ...}
# Convert to Strawberry object
return User(**user_data) # Unpacks dict as keyword arguments
# Without conversion, you'll get errors like:
# 'dict' object has no attribute 'author_id'Pattern to follow: Always convert database results (dicts) to Strawberry types before returning from resolvers: User(**user_data), [Post(**p) for p in posts_data]
Run the server:
uvicorn schema:app --reload
Access the GraphQL API:
- GraphQL endpoint: http://localhost:8000/graphql
- GraphiQL IDE: http://localhost:8000/graphql (opens in browser with interactive playground)
Result: Working GraphQL API
After starting the server, visit http://localhost:8000/graphql
GraphiQL IDE opens with:
- ✓ Interactive query editor with syntax highlighting
- ✓ Auto-complete for all types, fields, and arguments
- ✓ Documentation explorer (click "Docs" to browse schema)
- ✓ Query history and variables panel
- ✓ Try queries immediately without writing client code
Adding Context (Authentication)
GraphQL context allows you to pass data (like authenticated user info) to all resolvers. Let's add JWT authentication to our API.
First, install PyJWT for token handling:
pip install PyJWT
Then add authentication context to your GraphQL API:
# auth.py
from fastapi import Request, Depends
from strawberry.fastapi import GraphQLRouter
import jwt
from database import database
SECRET_KEY = "your-secret-key"
def get_current_user(request: Request):
"""
Extract user from JWT token in Authorization header.
Returns User object or None if not authenticated.
"""
auth_header = request.headers.get("Authorization")
if not auth_header:
return None
try:
# Extract token from "Bearer <token>"
token = auth_header.replace("Bearer ", "")
# Decode JWT
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
# Get user from database
user = database.get_user(payload["user_id"])
return user
except (jwt.InvalidTokenError, KeyError):
return None
async def get_context(
request: Request,
user = Depends(get_current_user)
):
"""
Build GraphQL context for each request.
Context is passed to all resolvers via info.context
"""
return {
"request": request,
"user": user
}
# Create GraphQL router with context
graphql_app = GraphQLRouter(
schema,
context_getter=get_context
)
app.include_router(graphql_app, prefix="/graphql")
# Now resolvers can access user:
@strawberry.mutation
def create_post(self, input: CreatePostInput, info: strawberry.Info) -> Post:
user = info.context["user"] # Get authenticated user
if not user:
raise Exception("Authentication required")
# Create post for authenticated user
post = database.create_post(
title=input.title,
body=input.body,
author_id=user.id
)
return post
# Client sends requests with Authorization header:
# curl -X POST http://localhost:8000/graphql # -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." # -H "Content-Type: application/json" # -d '{"query": "mutation { createPost(input: {title: "Test"}) { id } }"}'N+1 Problem and DataLoaders
The N+1 problem kills GraphQL performance. When fetching a list of posts with authors, a naive resolver makes 1 query for posts + N queries for authors (one per post). DataLoaders batch and cache these queries.
The N+1 Problem Explained
# Query: Get posts with authors
query {
posts(limit: 100) {
title
author {
username
}
}
}
# Naive resolver implementation:
@strawberry.field
def posts(self, limit: int = 10) -> List[Post]:
# Query 1: Fetch all posts
posts = database.execute("SELECT * FROM posts LIMIT %s", [limit])
return posts
@strawberry.field
def author(self) -> User:
# Called for EACH post!
# Query 2-101: Fetch author for each post
author = database.execute("SELECT * FROM users WHERE id = %s", [self.author_id])
return author
# Total queries:
# 1 query for posts
# + 100 queries for authors (one per post)
# = 101 queries!
# Database logs:
SELECT * FROM posts LIMIT 100
SELECT * FROM users WHERE id = 1
SELECT * FROM users WHERE id = 2
SELECT * FROM users WHERE id = 3
...
SELECT * FROM users WHERE id = 100
# Problem:
# - 101 database queries for simple request
# - Each query has latency (1-10ms)
# - Total time: 100-1000ms instead of 10-20ms
# - Database connection pool exhaustion
# - Scales horribly (1000 posts = 1001 queries!)N+1 Problem Impact
Performance with 100 posts:
- Naive approach: 101 queries, ~500ms
- With DataLoader: 2 queries (posts + batched users), ~15ms
- Result: 33× faster, 50× fewer queries
Solution: DataLoaders
DataLoaders batch multiple requests into a single database query and cache results to avoid duplicate fetches.
Install Strawberry with DataLoader support:
pip install strawberry-graphql[dataloader]
Then implement DataLoaders to batch database queries:
from strawberry.dataloader import DataLoader
from typing import List
from database import database
# Create DataLoader for users
async def load_users(keys: List[int]) -> List[User]:
"""
Batch function - receives list of user IDs,
returns list of users in same order.
Called once per request with all accumulated IDs.
"""
print(f"Loading users: {keys}")
# Single query with IN clause
query = "SELECT * FROM users WHERE id IN (%s)" % ",".join(str(k) for k in keys)
users = database.execute(query)
# Create mapping
user_map = {user.id: user for user in users}
# Return in same order as keys (important!)
return [user_map.get(key) for key in keys]
# Create DataLoader instance
user_loader = DataLoader(load_fn=load_users)
# Use DataLoader in resolver
@strawberry.field
async def author(self, info: strawberry.Info) -> User:
"""
Fetch author using DataLoader.
Instead of immediate database query,
DataLoader accumulates all author_ids,
then batches them into single query.
"""
# Get loader from context
loader = info.context["user_loader"]
# Load user (batched automatically)
user = await loader.load(self.author_id)
return user
# Add DataLoader to context
async def get_context(request: Request):
return {
"user_loader": DataLoader(load_fn=load_users)
}
# Execution flow with 100 posts:
# 1. Query posts
# 2. For each post, resolver calls loader.load(author_id)
# 3. DataLoader accumulates: [1, 2, 3, ..., 100]
# 4. After all resolvers enqueued, DataLoader calls load_users([1,2,3,...,100])
# 5. Single query: SELECT * FROM users WHERE id IN (1,2,3,...,100)
# 6. Results distributed to waiting resolvers
# Database logs with DataLoader:
SELECT * FROM posts LIMIT 100
SELECT * FROM users WHERE id IN (1,2,3,4,5,...,100)
# Total: 2 queries instead of 101!Result: Dramatic Performance Improvement
Query 100 posts with authors:
| Approach | Database Queries | Response Time |
|---|---|---|
| Without DataLoader | 101 queries | ~500ms |
| With DataLoader | 2 queries | ~15ms (33× faster!) |
DataLoader with Caching
# DataLoaders also cache within a request
# Query: Posts with authors AND comments with authors
query {
posts {
title
author { # Author loaded here
name
}
comments {
text
author { # Same author? Cached, no duplicate fetch!
name
}
}
}
}
# Without cache:
# 1. Fetch posts
# 2. Batch fetch post authors
# 3. Fetch comments for each post
# 4. Batch fetch comment authors (duplicates post authors!)
# With DataLoader cache:
# 1. Fetch posts
# 2. Batch fetch post authors (cached)
# 3. Fetch comments
# 4. Batch fetch comment authors
# - Author ID 1 already cached from step 2 → skip!
# - Author ID 5 already cached from step 2 → skip!
# - Only fetch new authors
# Result:
# - No duplicate fetches for same user
# - Further reduces queries
# - Cache is per-request (doesn't leak between requests)Authentication and Authorization
GraphQL needs security just like REST. Authenticate users, check permissions before resolving fields, and handle sensitive data carefully.
Field-Level Authorization
import strawberry
from strawberry.permission import BasePermission
from typing import Any
# Custom permission classes
class IsAuthenticated(BasePermission):
"""Require authentication"""
message = "User is not authenticated"
def has_permission(self, source: Any, info: strawberry.Info, **kwargs) -> bool:
return info.context.get("user") is not None
class IsAdmin(BasePermission):
"""Require admin role"""
message = "Admin access required"
def has_permission(self, source: Any, info: strawberry.Info, **kwargs) -> bool:
user = info.context.get("user")
return user and user.role == "admin"
class IsOwner(BasePermission):
"""Require ownership of resource"""
message = "You don't have permission to access this resource"
def has_permission(self, source: Any, info: strawberry.Info, **kwargs) -> bool:
user = info.context.get("user")
if not user:
return False
# source is the parent object (e.g., Post)
# Check if user owns it
return source.author_id == user.id
# Apply permissions to fields
@strawberry.type
class User:
id: strawberry.ID
username: str
@strawberry.field(permission_classes=[IsAuthenticated])
def email(self) -> str:
"""Email only visible to authenticated users"""
return self._email
@strawberry.field(permission_classes=[IsOwner])
def private_notes(self) -> str:
"""Private notes only visible to owner"""
return self._private_notes
# Apply permissions to mutations
@strawberry.type
class Mutation:
@strawberry.mutation(permission_classes=[IsAuthenticated])
def create_post(self, input: CreatePostInput, info: strawberry.Info) -> Post:
"""Only authenticated users can create posts"""
user = info.context["user"]
post = database.create_post(
title=input.title,
body=input.body,
author_id=user.id
)
return post
@strawberry.mutation(permission_classes=[IsAdmin])
def delete_user(self, id: strawberry.ID) -> bool:
"""Only admins can delete users"""
database.delete_user(id)
return True
# Query with permission check
@strawberry.type
class Query:
@strawberry.field(permission_classes=[IsAdmin])
def all_users(self) -> List[User]:
"""Admin-only query"""
return database.get_all_users()
# When permission denied, GraphQL returns error:
{
"data": {
"createPost": null
},
"errors": [
{
"message": "User is not authenticated",
"path": ["createPost"]
}
]
}Custom Authorization Logic
@strawberry.mutation
def update_post(
self,
id: strawberry.ID,
title: Optional[str],
info: strawberry.Info
) -> Post:
"""Update post with custom authorization logic"""
# Get authenticated user
user = info.context.get("user")
if not user:
raise Exception("Authentication required")
# Fetch post
post = database.get_post(id)
if not post:
raise Exception("Post not found")
# Check authorization - owner or admin can update
is_owner = post.author_id == user.id
is_admin = user.role == "admin"
if not (is_owner or is_admin):
raise Exception("Not authorized to update this post")
# Update post
if title:
post = database.update_post(id, title=title)
return post
# Hide sensitive fields based on context
@strawberry.type
class User:
id: strawberry.ID
username: str
@strawberry.field
def email(self, info: strawberry.Info) -> Optional[str]:
"""
Email visibility rules:
- Own email: always visible
- Other's email: only if authenticated and friends
- Public: not visible
"""
current_user = info.context.get("user")
# Not authenticated - hide email
if not current_user:
return None
# Own email - show
if current_user.id == self.id:
return self._email
# Friend's email - show
if database.are_friends(current_user.id, self.id):
return self._email
# Otherwise - hide
return None
# Rate limiting per user
from collections import defaultdict
import time
request_counts = defaultdict(list)
def check_rate_limit(user_id: str, limit: int = 100, window: int = 60) -> bool:
"""Check if user exceeded rate limit"""
now = time.time()
window_start = now - window
# Remove old timestamps
timestamps = request_counts[user_id]
timestamps[:] = [ts for ts in timestamps if ts > window_start]
# Check limit
if len(timestamps) >= limit:
return False
timestamps.append(now)
return True
@strawberry.type
class Query:
@strawberry.field
def expensive_query(self, info: strawberry.Info) -> str:
"""Rate-limited expensive query"""
user = info.context.get("user")
if not user:
raise Exception("Authentication required")
# Check rate limit
if not check_rate_limit(user.id, limit=10, window=60):
raise Exception("Rate limit exceeded. Max 10 requests per minute.")
# Execute expensive operation
result = perform_expensive_computation()
return resultProduction Best Practices
Production GraphQL requires query complexity limits, monitoring, caching strategies, and performance optimization. Learn from real-world deployments.
Query Complexity and Depth Limiting
# Problem: Malicious or accidental expensive queries
# Deeply nested query (DoS attack)
query MaliciousQuery {
users {
posts {
comments {
author {
posts {
comments {
author {
posts {
# ... infinite nesting possible!
}
}
}
}
}
}
}
}
}
# Result: Massive database load, server crash
# Solution: Query depth limiting
from strawberry.extensions import QueryDepthLimiter
schema = strawberry.Schema(
query=Query,
mutation=Mutation,
extensions=[
QueryDepthLimiter(max_depth=5) # Limit to 5 levels deep
]
)
# Query depth > 5 rejected:
{
"errors": [
{
"message": "Query exceeds maximum depth of 5"
}
]
}
# Query complexity analysis
# Assign cost to each field, reject if total > limit
from strawberry.extensions import QueryCostAnalyzer
@strawberry.type
class Query:
@strawberry.field
def users(self, limit: int = 10) -> List[User]:
"""Cost: 1 + limit (fetching N users)"""
return database.get_users(limit)
@strawberry.field
def expensive_report(self) -> Report:
"""Cost: 100 (very expensive operation)"""
return generate_report()
# Extension to calculate and limit cost
schema = strawberry.Schema(
query=Query,
extensions=[
QueryCostAnalyzer(
max_cost=1000 # Reject queries with cost > 1000
)
]
)
# Timeout protection
import asyncio
async def timeout_wrapper(resolver, timeout=5):
"""Wrap resolver with timeout"""
try:
return await asyncio.wait_for(resolver(), timeout=timeout)
except asyncio.TimeoutError:
raise Exception(f"Query timeout after {timeout}s")Caching GraphQL Responses
GraphQL caching is tricky because responses vary by query. Here are three strategies to improve performance through caching.
1. Persisted Queries
Clients register queries with the server and get a query ID. Future requests send the ID instead of the full query text.
# Registration:
POST /graphql/register
{
"query": "query GetUser($id: ID!) { user(id: $id) { name email } }",
"id": "abc123"
}
# Usage:
POST /graphql
{
"queryId": "abc123",
"variables": {"id": "123"}
}
# Benefits:
# - Smaller request size (ID instead of full query)
# - Can cache responses by ID + variables
# - Prevents arbitrary queries (security)2. Field-Level Caching
Cache individual field resolver results using Redis to avoid expensive recomputations.
Install Redis client:
pip install redis
Then implement field-level caching:
import redis
from functools import wraps
import json
redis_client = redis.Redis()
def cached_field(ttl: int = 300):
"""Cache field resolver results"""
def decorator(func):
@wraps(func)
async def wrapper(self, info: strawberry.Info, **kwargs):
# Generate cache key from field name + arguments
cache_key = f"gql:{func.__name__}:{self.id}:{kwargs}"
# Try cache
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)
# Compute
result = await func(self, info, **kwargs)
# Cache
redis_client.setex(cache_key, ttl, json.dumps(result))
return result
return wrapper
return decorator
@strawberry.type
class User:
id: strawberry.ID
@strawberry.field
@cached_field(ttl=300) # Cache for 5 minutes
async def posts(self, limit: int = 10) -> List[Post]:
"""Cached field - expensive to compute"""
return await database.get_posts_by_user(self.id, limit)3. HTTP Caching with GET
GraphQL usually uses POST, but supporting GET requests allows HTTP caching at the CDN/browser level.
# GraphQL GET request
GET /graphql?query={user(id:"123"){name}}&variables={}
# Response with Cache-Control header:
HTTP/1.1 200 OK
Cache-Control: public, max-age=300
Content-Type: application/json
{
"data": {
"user": {
"name": "Alice"
}
}
}
# CDN/browser can cache this response for 5 minutes!Monitoring and Performance
from prometheus_client import Counter, Histogram
# GraphQL-specific metrics
GRAPHQL_REQUESTS = Counter(
'graphql_requests_total',
'GraphQL requests',
['operation_type', 'operation_name']
)
GRAPHQL_ERRORS = Counter(
'graphql_errors_total',
'GraphQL errors',
['error_type']
)
GRAPHQL_RESOLVER_DURATION = Histogram(
'graphql_resolver_duration_seconds',
'Resolver execution time',
['type_name', 'field_name']
)
GRAPHQL_QUERY_DEPTH = Histogram(
'graphql_query_depth',
'Query depth',
buckets=[1, 2, 3, 5, 10, 20]
)
# Extension to collect metrics
from strawberry.extensions import Extension
class MetricsExtension(Extension):
def on_operation(self):
# Track operation
operation_type = self.execution_context.operation_type
operation_name = self.execution_context.operation_name
GRAPHQL_REQUESTS.labels(
operation_type=operation_type,
operation_name=operation_name
).inc()
def on_error(self, error):
# Track errors
GRAPHQL_ERRORS.labels(error_type=type(error).__name__).inc()
# Add to schema
schema = strawberry.Schema(
query=Query,
extensions=[MetricsExtension]
)
# Log slow queries
import logging
logger = logging.getLogger(__name__)
class SlowQueryLogger(Extension):
def on_operation_end(self):
duration = self.execution_context.duration
if duration > 1.0: # Slower than 1 second
query = self.execution_context.query
variables = self.execution_context.variables
logger.warning(
f"Slow query detected: {duration:.2f}s\n"
f"Query: {query}\n"
f"Variables: {variables}"
)Production Checklist
GraphQL Production Checklist
- ✓ Authentication: Verify user identity in context
- ✓ Authorization: Field-level permissions for sensitive data
- ✓ DataLoaders: Batch database queries to avoid N+1
- ✓ Query limits: Max depth 10, max complexity 1000
- ✓ Timeouts: 5-10 second timeout per query
- ✓ Rate limiting: 100-1000 requests per minute per user
- ✓ Caching: Field-level or persisted queries
- ✓ Monitoring: Track query duration, errors, depth
- ✓ Error handling: Return proper GraphQL errors
- ✓ Pagination: Cursor-based pagination for large lists
- ✓ Documentation: Schema descriptions for all types/fields
- ✓ Introspection: Disable in production or require auth
Real-World GraphQL APIs
| Company | GraphQL Use Cases | Public API |
|---|---|---|
| GitHub | Repository data, issues, PRs, code search | api.github.com/graphql |
| Shopify | Storefront API, products, orders, customers | Storefront + Admin API |
| Yelp | Business search, reviews, photos | api.yelp.com/v3/graphql |
| Tweets, users, timelines (internal) | Internal use | |
| Netflix | Content catalog, recommendations (internal) | Internal use |
Bonus: GraphQL — Another Flavour in a World of APIs
This comprehensive article explores GraphQL as an alternative approach to building APIs, covering everything from core concepts to a complete hands-on implementation with performance benchmarks comparing GraphQL against REST and gRPC.
The article explores:
- GraphQL fundamentals - Schemas, resolvers, queries, mutations, subscriptions, and introspection
- Building with Graphene-Python - Complete example using Flask-GraphQL to create a ticket management API
- Performance benchmarks - Real test results comparing GraphQL, FastAPI, Flask, and gRPC (2000 requests)
- GraphQL vs REST trade-offs - Auto-documentation and single entry point vs code complexity and performance