GraphQL vs REST API: Complete Comparison Guide for 2024
Understand when to use GraphQL vs REST API. Compare performance, flexibility, caching, real-world use cases, and learn practical implementation with code examples in Python and Node.js.
Introduction
The GraphQL vs REST debate continues to be one of the most discussed topics in API design. Having implemented both at scale—REST APIs handling millions of requests and GraphQL for complex data requirements—I'll share practical insights to help you make the right choice.
Quick Comparison
| Aspect | REST | GraphQL | |--------|------|---------| | Data Fetching | Fixed endpoints | Flexible queries | | Over-fetching | Common problem | Solved | | Under-fetching | Multiple requests | Single request | | Caching | HTTP caching | Complex, custom | | Learning Curve | Lower | Higher | | Tooling | Mature ecosystem | Growing rapidly | | File Upload | Native support | Needs workarounds | | Real-time | Requires WebSocket | Subscriptions built-in |
REST API Overview
Traditional REST Approach
REST endpoints for a blog
GET /api/posts # List all posts
GET /api/posts/123 # Get single post
POST /api/posts # Create post
PUT /api/posts/123 # Update post
DELETE /api/posts/123 # Delete postNested resources
GET /api/posts/123/comments # Get post comments
GET /api/users/456/posts # Get user's posts
REST Implementation (Python/FastAPI)
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import List, Optionalapp = FastAPI()
class Post(BaseModel):
id: int
title: str
content: str
author_id: int
tags: List[str]
created_at: str
class PostCreate(BaseModel):
title: str
content: str
tags: List[str] = []
@app.get("/api/posts", response_model=List[Post])
async def list_posts(
page: int = 1,
limit: int = 10,
author_id: Optional[int] = None
):
# Returns ALL fields even if client only needs title
posts = await db.get_posts(page, limit, author_id)
return posts
@app.get("/api/posts/{post_id}", response_model=Post)
async def get_post(post_id: int):
post = await db.get_post(post_id)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
return post
@app.get("/api/posts/{post_id}/comments")
async def get_post_comments(post_id: int):
# Separate request needed for comments
return await db.get_comments(post_id)
REST Problems: Over-fetching and Under-fetching
// Client needs: post title + author name + comment count// Problem 1: Over-fetching
// GET /api/posts/123 returns everything:
{
"id": 123,
"title": "My Post", // ✓ needed
"content": "Very long...", // ✗ not needed (wasted bandwidth)
"author_id": 456, // need author NAME, not just ID
"tags": ["tech", "api"], // ✗ not needed
"created_at": "2024-01-01", // ✗ not needed
"updated_at": "2024-01-02", // ✗ not needed
"view_count": 1000, // ✗ not needed
"likes": 50 // ✗ not needed
}
// Problem 2: Under-fetching (need multiple requests)
// Request 1: GET /api/posts/123
// Request 2: GET /api/users/456 (to get author name)
// Request 3: GET /api/posts/123/comments (to count)
// 3 HTTP round-trips for one screen!
GraphQL Overview
GraphQL Query Language
Define schema
type Post {
id: ID!
title: String!
content: String!
author: User!
comments: [Comment!]!
tags: [String!]!
createdAt: DateTime!
}type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}
type Comment {
id: ID!
content: String!
author: User!
}
type Query {
post(id: ID!): Post
posts(page: Int, limit: Int): [Post!]!
user(id: ID!): User
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
}
GraphQL Query - Get Exactly What You Need
Client queries only what's needed
query GetPostDetails($postId: ID!) {
post(id: $postId) {
title # ✓ just what we need
author {
name # ✓ author name directly
}
comments {
id # ✓ can count these
}
}
}Response - no over-fetching!
{
"data": {
"post": {
"title": "My Post",
"author": {
"name": "John Doe"
},
"comments": [
{ "id": "1" },
{ "id": "2" }
]
}
}
}ONE request instead of three!
GraphQL Implementation (Python/Strawberry)
import strawberry
from strawberry.fastapi import GraphQLRouter
from typing import List, Optional@strawberry.type
class User:
id: strawberry.ID
name: str
email: str
@strawberry.field
async def posts(self, info) -> List["Post"]:
# Lazy loading - only fetched if requested
return await info.context.loaders.user_posts.load(self.id)
@strawberry.type
class Comment:
id: strawberry.ID
content: str
author: User
@strawberry.type
class Post:
id: strawberry.ID
title: str
content: str
@strawberry.field
async def author(self, info) -> User:
# DataLoader prevents N+1 queries
return await info.context.loaders.users.load(self.author_id)
@strawberry.field
async def comments(self, info) -> List[Comment]:
return await info.context.loaders.post_comments.load(self.id)
@strawberry.type
class Query:
@strawberry.field
async def post(self, id: strawberry.ID, info) -> Optional[Post]:
return await info.context.db.get_post(id)
@strawberry.field
async def posts(
self,
info,
page: int = 1,
limit: int = 10
) -> List[Post]:
return await info.context.db.get_posts(page, limit)
@strawberry.type
class Mutation:
@strawberry.mutation
async def create_post(
self,
title: str,
content: str,
info
) -> Post:
user = info.context.user
return await info.context.db.create_post(user.id, title, content)
schema = strawberry.Schema(query=Query, mutation=Mutation)
graphql_app = GraphQLRouter(schema)
Performance Comparison
Network Efficiency
REST (3 requests for post details):
┌─────────┐ ┌─────────┐
│ Client │────►│ Server │ GET /posts/123 ~50ms
│ │◄────│ │
│ │────►│ │ GET /users/456 ~50ms
│ │◄────│ │
│ │────►│ │ GET /posts/123/comments ~50ms
│ │◄────│ │
└─────────┘ └─────────┘
Total: ~150ms + overheadGraphQL (1 request):
┌─────────┐ ┌─────────┐
│ Client │────►│ Server │ POST /graphql ~80ms
│ │◄────│ │ (all data in one trip)
└─────────┘ └─────────┘
Total: ~80ms
Caching Differences
REST - Easy HTTP Caching
GET /api/posts/123
Response headers:
Cache-Control: max-age=3600
ETag: "abc123"
Browser/CDN automatically caches based on URL
GraphQL - Complex Caching
All requests go to POST /graphql
Same URL, different queries - can't use HTTP caching directly
Solutions for GraphQL caching:
1. Persisted queries (query hash as ID)
2. Client-side normalized caching (Apollo, Relay)
3. CDN with query parsing (like Apollo Router)
Apollo Client example
const client = new ApolloClient({
cache: new InMemoryCache({
typePolicies: {
Post: {
keyFields: ["id"], # Normalize by ID
},
},
}),
});
When to Use REST
1. Simple CRUD Operations
REST excels at straightforward CRUD
Clean, predictable, easy to understand
@app.post("/api/users")
async def create_user(user: UserCreate):
return await db.create_user(user)
@app.get("/api/users/{id}")
async def get_user(id: int):
return await db.get_user(id)
@app.put("/api/users/{id}")
async def update_user(id: int, user: UserUpdate):
return await db.update_user(id, user)
@app.delete("/api/users/{id}")
async def delete_user(id: int):
return await db.delete_user(id)
2. Public APIs
REST advantages for public APIs:
✓ Universally understood
✓ Easy documentation (OpenAPI/Swagger)
✓ Works with any HTTP client
✓ Simple rate limiting per endpoint
✓ Cacheable by CDNs
3. File Uploads
REST - Native multipart support
@app.post("/api/upload")
async def upload_file(file: UploadFile):
content = await file.read()
url = await storage.save(file.filename, content)
return {"url": url}GraphQL requires workarounds
- Base64 encoding (inefficient)
- Separate REST endpoint
- graphql-upload spec (adds complexity)
4. Microservices Communication
REST for internal service-to-service
Simple, fast, well-understood
Service A ──HTTP/REST──► Service B
──HTTP/REST──► Service C
──HTTP/REST──► Service D
Easy to implement, debug, and monitor
When to Use GraphQL
1. Complex, Nested Data Requirements
One query to get complex nested data
query DashboardData {
currentUser {
name
notifications(unread: true) {
message
createdAt
}
recentOrders(limit: 5) {
id
status
items {
product {
name
image
}
quantity
}
}
}
featuredProducts(limit: 10) {
name
price
reviews {
rating
}
}
}REST would need 4-5 separate requests
2. Mobile Applications
GraphQL advantages for mobile:
✓ Minimize bandwidth usage
✓ Reduce battery drain (fewer requests)
✓ Adapt to different screen sizes
✓ Offline-first with normalized cachePhone gets minimal data
query MobilePostList {
posts { id, title, thumbnail }
}Tablet gets more
query TabletPostList {
posts { id, title, excerpt, thumbnail, author { name } }
}
3. Rapidly Evolving Frontend
Frontend can request new fields without backend changes
(as long as field exists in schema)
Version 1
query { user { name, email } }Version 2 - Added avatar, no backend change needed
query { user { name, email, avatar } }Version 3 - Added social links
query { user { name, email, avatar, socialLinks { twitter, linkedin } } }
4. Real-time Features
GraphQL Subscriptions for real-time
subscription OnNewMessage($chatId: ID!) {
messageAdded(chatId: $chatId) {
id
content
sender {
name
avatar
}
createdAt
}
}Client receives updates automatically
Built into GraphQL spec
Hybrid Approach
Best of Both Worlds:
┌─────────────────────────────────────────────┐
│ API Gateway │
└──────────────────┬──────────────────────────┘
│
┌─────────────┼─────────────┐
│ │ │
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌─────────┐
│ REST │ │ GraphQL │ │ REST │
│ /upload │ │ /graphql │ │ /health │
│ /webhook│ │ │ │ /metrics│
└─────────┘ └──────────┘ └─────────┘Use REST for:
- File uploads
- Webhooks
- Health checks
- Simple public APIs
Use GraphQL for:
- Complex data fetching
- Frontend-driven queries
- Real-time features
N+1 Problem and Solutions
The N+1 Problem
Query: Get 10 posts with their authors
Without DataLoader:
posts = db.get_posts(limit=10) # 1 query
for post in posts:
post.author = db.get_user(post.author_id) # 10 queries!
Total: 11 queries (1 + N)
With DataLoader:
from strawberry.dataloader import DataLoaderasync def load_users(user_ids: List[int]) -> List[User]:
# Batch all user IDs into ONE query
users = await db.get_users_by_ids(user_ids)
# Return in same order as input
user_map = {u.id: u for u in users}
return [user_map.get(id) for id in user_ids]
user_loader = DataLoader(load_fn=load_users)
Now:
posts = db.get_posts(limit=10) # 1 query
authors = await user_loader.load_many([p.author_id for p in posts]) # 1 query
Total: 2 queries!
Security Considerations
GraphQL-Specific Security
1. Query Depth Limiting
from graphql import GraphQLErrordef depth_limit_validator(max_depth: int):
def validate(query_ast):
depth = calculate_depth(query_ast)
if depth > max_depth:
raise GraphQLError(f"Query depth {depth} exceeds max {max_depth}")
return validate
2. Query Complexity Analysis
@strawberry.type
class Query:
@strawberry.field(extensions=[ComplexityExtension(cost=10)])
async def expensive_query(self) -> List[Post]:
pass3. Rate Limiting by Complexity
Instead of requests/minute, limit by total complexity points
4. Disable Introspection in Production
schema = strawberry.Schema(
query=Query,
mutation=Mutation,
extensions=[
DisableIntrospection() if not DEBUG else None
]
)
Key Takeaways
| Choose REST When | Choose GraphQL When | |-----------------|---------------------| | Simple CRUD apps | Complex data needs | | Public APIs | Mobile-first apps | | File handling | Rapid iteration | | Microservices | Real-time features | | HTTP caching critical | Bandwidth critical | | Team new to APIs | Team experienced |
Conclusion
There's no universal winner. REST remains excellent for simple, public, and cache-heavy APIs. GraphQL shines with complex data requirements and mobile applications. Many successful companies use both—REST for simplicity where it fits, GraphQL for complex frontend needs. Choose based on your specific requirements, team expertise, and use case.
---
Building APIs? Connect on LinkedIn to discuss architecture decisions.