REST, gRPC, and GraphQL solve different problems. REST is the universal standard. gRPC optimizes for performance and type safety. GraphQL optimizes for client flexibility. The right choice depends on your team, performance requirements, and client diversity.
REST: The Universal Standard
REST uses HTTP methods with resource-oriented URLs and JSON serialization:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
class User(BaseModel):
id: int
name: str
email: str
@app.get("/users/{user_id}", response_model=User)
async def get_user(user_id: int):
user = await db.fetch_user(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@app.post("/users", response_model=User, status_code=201)
async def create_user(user: User):
return await db.create_user(user)
# REST advantages:
# - Universal: every language, framework, and tool supports it
# - Cacheable: HTTP caching (ETags, Cache-Control) works automatically
# - Browser-native: works without client libraries
# - Simple: curl for debugging, OpenAPI for documentation// REST response — includes all fields, even if client doesn't need them
{
"id": 123,
"name": "Alice",
"email": "alice@example.com",
"created_at": "2026-01-15T10:30:00Z",
"profile": {
"avatar_url": "https://cdn.example.com/avatars/123.jpg",
"bio": "Software engineer",
"settings": { ... }
},
"organizations": [ ... ],
"recent_activity": [ ... ]
}
// Problem: over-fetching (client only needs name and email)
// Problem: under-fetching (need separate call for profile)gRPC: Performance and Type Safety
gRPC uses Protocol Buffers for serialization and HTTP/2 for transport:
// user.proto
syntax = "proto3";
package users;
service UserService {
rpc GetUser (GetUserRequest) returns (User);
rpc CreateUser (CreateUserRequest) returns (User);
rpc ListUsers (ListUsersRequest) returns (stream User);
}
message User {
int32 id = 1;
string name = 2;
string email = 3;
google.protobuf.Timestamp created_at = 4;
}
message GetUserRequest {
int32 id = 1;
}// Server implementation (Go)
type userServer struct {
pb.UnimplementedUserServiceServer
}
func (s *userServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
user, err := s.db.GetUser(req.Id)
if err != nil {
return nil, status.Errorf(codes.NotFound, "user %d not found", req.Id)
}
return &pb.User{
Id: int32(user.ID),
Name: user.Name,
Email: user.Email,
}, nil
}
// Streaming: server streams results as they're found
func (s *userServer) ListUsers(req *pb.ListUsersRequest, stream pb.UserService_ListUsersServer) error {
users, err := s.db.ListUsers(req.Filter)
if err != nil {
return err
}
for _, user := range users {
if err := stream.Send(&pb.User{
Id: int32(user.ID),
Name: user.Name,
Email: user.Email,
}); err != nil {
return err
}
}
return nil
}GraphQL: Client-Driven Data Fetching
GraphQL lets clients specify exactly what data they need:
type Query {
user(id: ID!): User
users(filter: UserFilter): [User!]!
}
type User {
id: ID!
name: String!
email: String!
profile: Profile!
organizations: [Organization!]!
}
# Client request: only fetch what's needed
query {
user(id: 123) {
name
email
profile {
avatarUrl
}
}
}
# Response: exactly what was requested
{
"data": {
"user": {
"name": "Alice",
"email": "alice@example.com",
"profile": {
"avatarUrl": "https://cdn.example.com/avatars/123.jpg"
}
}
}
}# GraphQL server (Python with Strawberry)
import strawberry
from typing import Optional
@strawberry.type
class User:
id: int
name: str
email: str
@strawberry.type
class Query:
@strawberry.field
async def user(self, id: int) -> Optional[User]:
return await db.fetch_user(id)
@strawberry.field
async def users(self, limit: int = 10) -> list[User]:
return await db.list_users(limit)
schema = strawberry.Schema(query=Query)
# N+1 problem: each user triggers separate DB query
# Solution: DataLoader batches queries per request
from strawberry.dataloader import DataLoader
async def load_users(ids: list[int]) -> list[User]:
return await db.get_users_by_ids(ids)
user_loader = DataLoader(load_fn=load_users)Performance Comparison
# Benchmark: 1000 requests for user profile with 5 related entities
#
# REST (3 calls needed: /users/123, /users/123/profile, /users/123/orgs):
# Avg latency: 45ms (3 round trips)
# Payload: 2.1 KB
# Server CPU: 100% baseline
#
# gRPC (1 call, all fields):
# Avg latency: 8ms (1 round trip, binary)
# Payload: 0.2 KB (protobuf)
# Server CPU: 60% (less serialization)
#
# GraphQL (1 call, specified fields):
# Avg latency: 12ms (1 round trip, JSON)
# Payload: 0.4 KB (only requested fields)
# Server CPU: 80% (query planning overhead)
# For mobile on 3G:
# REST: ~300ms (3 round trips at 100ms each)
# gRPC: ~120ms (1 round trip, small payload)
# GraphQL: ~150ms (1 round trip, moderate payload)tradeoff / REST vs gRPC vs GraphQL
For most teams, REST is the right default. Add gRPC for performance-critical internal communication. Add GraphQL when client diversity creates over/under-fetching problems. Don't use all three for the same API.
Use REST for public APIs (universal compatibility). Use gRPC for internal service-to-service (performance). Use GraphQL for client-facing APIs with diverse data needs (mobile + web from same backend).
Synthesis
REST, gRPC, and GraphQL optimize for different constraints. REST maximizes interoperability. gRPC maximizes performance and type safety. GraphQL maximizes client flexibility. The best choice depends on your client types, performance requirements, and team expertise. Most production systems use REST for external APIs and gRPC for internal communication.