Skip to main content

GraphQL vs REST: API Design Patterns and Best Practices

Published: February 21, 2026 Updated: May 24, 2026 Larry Qu 11 min read

Choosing the right API paradigm is one of the most important architectural decisions you’ll make. REST has been the standard for years, but GraphQL has emerged as a powerful alternative that addresses many of REST’s limitations.

REST has been the standard API paradigm for years, but GraphQL has emerged as a powerful alternative. Understanding both helps you choose the right approach for your use case.

Understanding REST APIs

REST (Representational State Transfer) is an architectural style that uses HTTP methods to perform CRUD operations on resources.

REST Fundamentals

# REST API Endpoints

# GET - Retrieve resources
GET /api/users              # List all users
GET /api/users/123          # Get specific user
GET /api/users/123/orders   # Get user's orders

# POST - Create resources
POST /api/users             # Create new user

# PUT - Update resources (full update)
PUT /api/users/123          # Update entire user

# PATCH - Partial update
PATCH /api/users/123       # Update specific fields

# DELETE - Remove resources
DELETE /api/users/123      # Delete user

REST Response Format

// REST JSON Response
{
  "user": {
    "id": "123",
    "name": "John Doe",
    "email": "[email protected]",
    "created_at": "2025-01-15T10:30:00Z"
  }
}

// REST List Response with Pagination
{
  "data": [
    {"id": "1", "name": "User 1"},
    {"id": "2", "name": "User 2"}
  ],
  "pagination": {
    "page": 1,
    "per_page": 20,
    "total": 100,
    "pages": 5
  }
}

REST Best Practices

# REST API Design Principles

naming_conventions:
  - Use nouns for resources: /users, /orders, /products
  - Use plural forms: /users not /user
  - Use kebab-case: /user-profiles not /userProfiles
  - Nested resources for relationships: /users/123/orders

http_status_codes:
  "200": "OK - Success"
  "201": "Created - Resource created"
  "204": "No Content - Successful deletion"
  "400": "Bad Request - Invalid input"
  "401": "Unauthorized - Authentication required"
  "403": "Forbidden - No permission"
  "404": "Not Found - Resource doesn't exist"
  "500": "Internal Server Error"

versioning:
  - URL versioning: /api/v1/users
  - Header versioning: Accept: application/vnd.api.v1+json
  - Query versioning: /api/users?version=1

Understanding GraphQL

GraphQL is a query language for APIs that allows clients to request exactly the data they need.

GraphQL Query Structure

# Simple GraphQL Query
query {
  user(id: "123") {
    name
    email
  }
}

# Response
{
  "data": {
    "user": {
      "name": "John Doe",
      "email": "[email protected]"
    }
  }
}

GraphQL Schema Definition

# Define Types
type User {
  id: ID!
  name: String!
  email: String!
  age: Int
  createdAt: DateTime!
  orders: [Order!]!
}

type Order {
  id: ID!
  userId: ID!
  total: Float!
  items: [OrderItem!]!
  status: OrderStatus!
  createdAt: DateTime!
}

enum OrderStatus {
  PENDING
  PAID
  SHIPPED
  DELIVERED
  CANCELLED
}

type OrderItem {
  productId: ID!
  product: Product!
  quantity: Int!
  price: Float!
}

type Product {
  id: ID!
  name: String!
  price: Float!
}

# Define Queries (Read)
type Query {
  user(id: ID!): User
  users(limit: Int, offset: Int): [User!]!
  order(id: ID!): Order
  orders(userId: ID): [Order!]!
}

# Define Mutations (Write)
type Mutation {
  createUser(input: CreateUserInput!): User!
  updateUser(id: ID!, input: UpdateUserInput!): User!
  deleteUser(id: ID!): Boolean!
  createOrder(input: CreateOrderInput!): Order!
}

input CreateUserInput {
  name: String!
  email: String!
  age: Int
}

input UpdateUserInput {
  name: String
  email: String
  age: Int
}

# Define Subscriptions (Real-time)
type Subscription {
  orderUpdated(userId: ID!): Order!
}

GraphQL with Python (Strawberry)

import strawberry
from typing import List, Optional
from datetime import datetime

@strawberry.type
class User:
    id: strawberry.ID
    name: str
    email: str
    age: Optional[int] = None
    created_at: datetime
    
    @strawberry.field
    def orders(self) -> List["Order"]:
        return get_orders_for_user(self.id)

@strawberry.type
class Query:
    @strawberry.field
    def user(self, id: strawberry.ID) -> Optional[User]:
        return db.get_user(id)
    
    @strawberry.field
    def users(self, limit: int = 20, offset: int = 0) -> List[User]:
        return db.get_users(limit=limit, offset=offset)

@strawberry.type
class Mutation:
    @strawberry.mutation
    def create_user(self, name: str, email: str) -> User:
        user = User(name=name, email=email)
        return db.save_user(user)

schema = strawberry.Schema(query=Query, mutation=Mutation)

Understanding tRPC

tRPC provides type-safe API communication without code generation by sharing TypeScript types between client and server.

tRPC Core Principles

┌─────────────────────────────────────────────────────────────┐
│                       tRPC                                      │
├─────────────────────────────────────────────────────────────┤
│  Core Principles:                                           │
│  • Type-safe API without code generation                   │
│  • Share TypeScript types between client and server        │
│  • Zero-schema API definitions                            │
│  • Works seamlessly with Next.js, React                    │
│                                                             │
│  Example:                                                   │
│  const user = await trpc.user.getById.query("123")         │
│  // Fully typed return without any setup                    │
└─────────────────────────────────────────────────────────────┘

tRPC Server Implementation

import { z } from 'zod';
import { createTRPCRouter, publicProcedure } from '../trpc';

export const userRouter = createTRPCRouter({
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(({ input, ctx }) => {
      return ctx.db.user.findUnique({
        where: { id: input.id }
      });
    }),

  getAll: publicProcedure.query(({ ctx }) => {
    return ctx.db.user.findMany();
  }),

  create: publicProcedure
    .input(z.object({
      name: z.string().min(1),
      email: z.string().email()
    }))
    .mutation(({ input, ctx }) => {
      return ctx.db.user.create({ data: input });
    }),
});

tRPC Client Usage

import { api } from '@/utils/api';

export default function UsersPage() {
  const { data: users, isLoading } = api.user.getAll.useQuery();

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      {users?.map(user => (
        <div key={user.id}>{user.name}</div>
      ))}
    </div>
  );
}

When to Use tRPC

  • Full-stack TypeScript projects (Next.js, React)
  • Internal APIs where type safety is critical
  • Teams wanting zero boilerplate
  • Not suitable for public APIs or non-TypeScript clients

Three-Way Comparison

Feature REST GraphQL tRPC
Data Fetching Multiple endpoints Single endpoint Multiple procedures
Over-fetching Common Minimal Minimal
Under-fetching Common Eliminated Eliminated
Typing Manual or code gen Code generation Automatic (native TypeScript)
Learning Curve Low Medium-High Low-Medium
Caching HTTP caching Client-side cache React Query integration
Public API Yes Yes No

REST vs GraphQL: Key Differences

Data Fetching Comparison

# REST: Multiple endpoints, over-fetching
# Request 1: Get user info
GET /api/users/123
# Returns: {id, name, email, age, created_at, ...}

# Request 2: Get user's orders (with items)
GET /api/users/123/orders
# Returns: [{id, total, items: [...], ...}]

# Request 3: Get product details for each item
GET /api/products/1
GET /api/products/2

# Total: 4 HTTP requests, lots of unused data


# GraphQL: Single request, exact data
query {
  user(id: "123") {
    name
    orders {
      total
      items {
        product {
          name
          price
        }
        quantity
      }
    }
  }
}

# Returns exactly what was requested
# Single HTTP request

Comparison Table

Aspect REST GraphQL
Data Fetching Multiple endpoints Single request
Over-fetching Common Eliminated
Under-fetching Common Eliminated
Caching HTTP caching (built-in) Custom/normalized cache
Learning Curve Lower Higher
Type Safety Optional Built-in
Real-time WebSockets separately Subscriptions
File Upload Native Complex

When to Use REST

# REST is better when:

use_cases:
  - Simple CRUD operations
  - Public APIs with documentation
  - Team unfamiliar with GraphQL
  - HTTP caching is important
  - Microservices with clear boundaries
  - Bandwidth-constrained environments
  - Already existing REST infrastructure

examples:
  - Banking APIs (strict standards)
  - Payment gateways
  - Mobile apps with limited data needs
  - Simple content APIs

REST Example: E-commerce API

# RESTful E-commerce API

# Products
GET    /api/products
GET    /api/products/{id}
POST   /api/products
PUT    /api/products/{id}
DELETE /api/products/{id}

# Orders
GET    /api/orders
GET    /api/orders/{id}
POST   /api/orders
PATCH  /api/orders/{id}/cancel

# Cart
GET    /api/cart
POST   /api/cart/items
PATCH  /api/cart/items/{product_id}
DELETE /api/cart/items/{product_id}
DELETE /api/cart

When to Use GraphQL

# GraphQL is better when:

use_cases:
  - Complex data relationships
  - Mobile apps (bandwidth saving)
  - Rapid frontend development
  - Aggregating multiple services
  - Highly interactive UIs
  - Team owns full stack
  - Schema evolution is important

examples:
  - Social media apps
  - Dashboard applications
  - BFF (Backend for Frontend) patterns
  - Data aggregation layers

GraphQL Example: Social Media API

# GraphQL for Social Media

# Single query can fetch:
# - User profile
# - Their posts
# - Comments on posts
# - Likes count
# - Follower count
# - Recent activity

query GetFeed($userId: ID!) {
  user(id: $userId) {
    name
    avatar
    posts(limit: 10) {
      content
      images
      likes {
        count
        recentUsers {
          name
          avatar
        }
      }
      comments {
        text
        author {
          name
        }
      }
    }
    stats {
      followers
      following
      postsCount
    }
  }
}

Security Considerations

REST Security

const rateLimit = require('express-rate-limit');

const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  message: { error: 'Too many requests, please try again later.' },
});

app.use('/api/', apiLimiter);

const authenticate = async (req, res, next) => {
  const token = req.headers.authorization?.replace('Bearer ', '');
  if (!token) return res.status(401).json({ error: 'No token provided' });
  try {
    req.user = await verifyToken(token);
    next();
  } catch {
    res.status(401).json({ error: 'Invalid token' });
  }
};

GraphQL Security

GraphQL’s flexible queries introduce unique security challenges:

# Dangerous query — deeply nested
query MaliciousQuery {
  users {
    posts {
      comments { author { posts { comments { author { name } } } } }
    }
  }
}
const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    depthLimit(5),
    costAnalysis({ maximumCost: 1000, defaultCost: 1 }),
  ],
});
Pattern REST GraphQL
API Key Header: X-Api-Key Header (middleware)
JWT Header: Authorization: Bearer <token> Header (middleware)
OAuth 2.0 Standard authorization flows Same approach
Rate Limiting Endpoint-based Query complexity-based

Quick Decision Guide

Scenario Recommendation
Public API for third-party developers REST
Simple CRUD operations REST
Cache-heavy workloads (CDN) REST
File uploads and binary data REST
Mobile apps (bandwidth saving) GraphQL
Complex data relationships GraphQL
Real-time subscriptions GraphQL
Full-stack TypeScript (internal) tRPC
Dashboard/data aggregation GraphQL
flowchart TD
    A[Start] --> B{Simple CRUD?}
    B -->|Yes| C[REST]
    B -->|No| D{Mobile app?}
    D -->|Yes| E[GraphQL]
    D -->|No| F{Public API?}
    F -->|Yes| G[REST or GraphQL]
    F -->|No| H{TypeScript full-stack?}
    H -->|Yes| I[tRPC]
    H -->|No| J{Complex relationships?}
    J -->|Yes| K[GraphQL]
    J -->|No| L[REST]

Adoption Considerations

Team Skills

REST requires less upfront learning. GraphQL requires schema design, resolver patterns, DataLoader batching, and caching strategies. tRPC requires TypeScript expertise and is best for internal APIs.

Tooling Ecosystem

Concern REST GraphQL tRPC
Documentation OpenAPI/Swagger (mature) GraphQL introspection TypeScript types
Testing Postman, Insomnia Apollo Studio, GraphiQL Native
Client generation OpenAPI Generator GraphQL Code Generator Automatic
Type safety zod/io-ts validators Generated TypeScript types Native TypeScript

Hybrid Approach: Using Both

Many successful APIs use both REST and GraphQL:

# Hybrid API Architecture

class HybridAPI:
    """
    Use REST for:
    - Simple CRUD operations
    - File uploads/downloads
    - Webhooks
    - Authentication endpoints
    
    Use GraphQL for:
    - Complex queries
    - Real-time updates
    - Dashboard data
    - Mobile app data
    """
    
    # REST for Auth
    REST_ENDPOINTS = {
        "POST /auth/login": "User login",
        "POST /auth/logout": "User logout",
        "POST /auth/refresh": "Refresh token",
    }
    
    # REST for Files
    REST_ENDPOINTS = {
        "POST /upload": "Upload file",
        "GET /download/{id}": "Download file",
    }
    
    # GraphQL for Data
    GRAPHQL_SCHEMA = """
        type Query {
            # Complex queries here
        }
        type Mutation {
            # Complex mutations here
        }
    """

API Design Patterns

Pagination

# REST Cursor-based Pagination
class CursorPagination:
    """
    Use cursor for large datasets
    """
    def get_items(self, cursor=None, limit=20):
        return db.query("""
            SELECT * FROM users 
            WHERE cursor > ? 
            ORDER BY cursor 
            LIMIT ?
        """, [cursor, limit])

# REST Offset-based Pagination  
class OffsetPagination:
    def get_items(self, page=1, per_page=20):
        offset = (page - 1) * per_page
        return db.query("""
            SELECT * FROM users 
            ORDER BY id 
            LIMIT ? OFFSET ?
        """, [per_page, offset])

# GraphQL Connection Specification
pagination_schema = """
    type UserConnection {
      edges: [UserEdge!]!
      pageInfo: PageInfo!
      totalCount: Int!
    }
    
    type UserEdge {
      node: User!
      cursor: String!
    }
    
    type PageInfo {
      hasNextPage: Boolean!
      hasPreviousPage: Boolean!
      startCursor: String
      endCursor: String
    }
"""

Error Handling

# REST Error Response
class RESTError:
    @app.errorhandler(ValidationError)
    def handle_validation(error):
        return {
            "error": {
                "code": "VALIDATION_ERROR",
                "message": str(error),
                "details": error.details
            }
        }, 400

# GraphQL Error Response
error_schema = """
    type Mutation {
        createUser(input: UserInput!): CreateUserPayload
    }
    
    union CreateUserPayload = User | ValidationError
    
    type ValidationError {
        field: String!
        message: String!
    }
"""

API Versioning

# REST Versioning Strategies

# 1. URL Versioning (most common)
@app.route('/api/v1/users')
@app.route('/api/v2/users')
def get_users():
    version = request.version
    ...

# 2. Header Versioning
@app.route('/api/users')
def get_users():
    version = request.headers.get('API-Version', 'v1')
    ...

# GraphQL Evolution (Preferred over Versioning)
evolution_schema = """
    type User {
        id: ID!
        name: String!
        email: String! @deprecated(reason: "Use contactEmail instead")
        contactEmail: String
    }
"""

Rate Limiting

# REST Rate Limiting (using headers)
@app.route('/api/users')
@ratelimit(limit=100, per=60)
def get_users():
    return {"users": [...]}

# Add rate limit headers
def rate_limiter(limit, per):
    def decorator(f):
        @wraps(f)
        def wrapped(*args, **kwargs):
            # Check limit
            remaining, reset = check_rate_limit()
            resp = make_response(f(*args, **kwargs))
            resp.headers['X-RateLimit-Limit'] = str(limit)
            resp.headers['X-RateLimit-Remaining'] = str(remaining)
            resp.headers['X-RateLimit-Reset'] = str(reset)
            return resp
        return wrapped
    return decorator

# GraphQL Rate Limiting
rate_limit_schema = """
    directive @rateLimit(
        limit: Int,
        window: String
    ) on FIELD_DEFINITION
    
    type Query {
        users: [User!]! @rateLimit(limit: 100, window: "1m")
    }
"""

Performance Optimization

REST Caching

# HTTP Caching with ETag
@app.route('/api/users/<id>')
def get_user(id):
    user = db.get_user(id)
    etag = md5(str(user.updated_at))
    
    if request.if_none_match == etag:
        return '', 304
    
    response = jsonify(user)
    response.headers['ETag'] = etag
    response.headers['Cache-Control'] = 'max-age=3600'
    return response

# Last-Modified Caching
@app.route('/api/users')
def get_users():
    users = db.get_users()
    last_modified = max(u.updated_at for u in users)
    
    if request.if_modified_since:
        if last_modified <= request.if_modified_since:
            return '', 304
    
    response = jsonify(users)
    response.headers['Last-Modified'] = last_modified
    return response

GraphQL DataLoader

# Solve N+1 problem with DataLoader
from dataloader import DataLoader

class UserLoader(DataLoader):
    def batch_load_fn(self, user_ids):
        users = db.get_users(user_ids)
        # Return list in same order as user_ids
        return [users.get(uid) for uid in user_ids]

class OrderLoader(DataLoader):
    def batch_load_fn(self, user_ids):
        orders = db.get_orders_for_users(user_ids)
        # Group orders by user_id
        orders_by_user = {}
        for order in orders:
            orders_by_user.setdefault(order.user_id, []).append(order)
        return [orders_by_user.get(uid, []) for uid in user_ids]

# Use in resolvers
@strawberry.field
def user(self, info: Info) -> User:
    loader = info.context['user_loader']
    return loader.load(self.user_id)

Conclusion

Both REST and GraphQL have their place in modern API development:

  • REST remains excellent for simple CRUD operations, public APIs, and scenarios where HTTP caching is crucial
  • GraphQL excels in complex data scenarios, mobile apps, and when frontend teams need flexibility

The best choice depends on your specific requirements, team expertise, and existing infrastructure. Many successful applications use both approaches strategically.


Comments

👍 Was this article helpful?