REST API Design Best Practices: Versioning, Pagination & Error Handling

•10 min read•Subid Das
api-designbackendrestdevopsbest-practices
REST API Design Best Practices: Versioning, Pagination & Error Handling

Bad API design causes bugs, breaks clients, and wastes engineering time. Good design scales to millions of requests. This guide covers production patterns.

1. Versioning Strategy#

URL Versioning (Explicit)#

GET /api/v1/users
GET /api/v2/users  -- Different response format

Pros: Clear, easy to deprecate Cons: Code duplication

@app.route('/api/v1/users', methods=['GET'])
def get_users_v1():
    users = User.all()
    return {
        "data": [{"id": u.id, "name": u.name} for u in users]
    }

@app.route('/api/v2/users', methods=['GET'])
def get_users_v2():
    # V2: Different structure
    users = User.all()
    return {
        "users": [{"user_id": u.id, "full_name": u.name} for u in users],
        "total": len(users),
        "timestamp": datetime.now().isoformat()
    }

Header Versioning (Implicit)#

GET /api/users
Accept-Version: 1.0

Pros: Clean URLs Cons: Hidden, harder to test

@app.route('/api/users', methods=['GET'])
def get_users():
    version = request.headers.get('Accept-Version', '1.0')
    users = User.all()
    
    if version == '1.0':
        return {"data": [{"id": u.id, "name": u.name} for u in users]}
    elif version == '2.0':
        return {"users": [...], "total": len(users)}

Recommendation: URL versioning. Explicit is better than implicit.

2. Pagination#

Prevent clients from fetching millions of records:

Offset-Limit Pagination#

GET /api/users?offset=100&limit=20

Response:

{
  "data": [...],
  "pagination": {
    "offset": 100,
    "limit": 20,
    "total": 5000,
    "has_more": true
  }
}
@app.route('/api/v1/users', methods=['GET'])
def get_users():
    offset = request.args.get('offset', default=0, type=int)
    limit = request.args.get('limit', default=20, type=int)
    
    # Cap limit (prevent DoS)
    limit = min(limit, 100)
    
    total = User.count()
    users = User.query.offset(offset).limit(limit).all()
    
    return {
        "data": [u.to_dict() for u in users],
        "pagination": {
            "offset": offset,
            "limit": limit,
            "total": total,
            "has_more": offset + limit < total
        }
    }

Problem: Offset is slow for large datasets (scans all skipped rows).

Cursor-Based Pagination (Better)#

GET /api/users?cursor=abc123&limit=20

Cursor encodes position:

import base64
import json

def encode_cursor(user_id, timestamp):
    data = {"id": user_id, "timestamp": timestamp}
    return base64.b64encode(json.dumps(data).encode()).decode()

def decode_cursor(cursor):
    return json.loads(base64.b64decode(cursor.encode()).decode())

@app.route('/api/v1/users', methods=['GET'])
def get_users():
    cursor = request.args.get('cursor')
    limit = request.args.get('limit', default=20, type=int)
    limit = min(limit, 100)
    
    if cursor:
        data = decode_cursor(cursor)
        # Fetch users AFTER cursor
        users = User.query.filter(User.id > data['id']).limit(limit + 1).all()
    else:
        users = User.query.limit(limit + 1).all()
    
    # Check if more results
    has_more = len(users) > limit
    users = users[:limit]
    
    # Generate next cursor
    next_cursor = None
    if has_more and users:
        next_cursor = encode_cursor(users[-1].id, users[-1].created_at)
    
    return {
        "data": [u.to_dict() for u in users],
        "pagination": {
            "cursor": cursor,
            "next_cursor": next_cursor,
            "limit": limit,
            "has_more": has_more
        }
    }

Pros: O(1) performance, handles insertions well Cons: Slightly complex, can't jump to page 500

Allow clients to filter:

GET /api/users?status=active&role=admin&created_after=2025-01-01

Implement with query validation:

from marshmallow import Schema, fields, validate

class UserFilterSchema(Schema):
    status = fields.Str(validate=validate.OneOf(['active', 'inactive']))
    role = fields.Str(validate=validate.OneOf(['admin', 'user']))
    created_after = fields.DateTime()
    created_before = fields.DateTime()

@app.route('/api/v1/users', methods=['GET'])
def get_users():
    schema = UserFilterSchema()
    filters = schema.load(request.args)  # Validates & deserializes
    
    query = User.query
    
    if filters.get('status'):
        query = query.filter_by(status=filters['status'])
    if filters.get('role'):
        query = query.filter_by(role=filters['role'])
    if filters.get('created_after'):
        query = query.filter(User.created_at >= filters['created_after'])
    
    users = query.limit(20).all()
    return {"data": [u.to_dict() for u in users]}

4. Error Handling#

Standard error format:

{
  "error": {
    "code": "USER_NOT_FOUND",
    "message": "User with ID 999 does not exist",
    "details": {
      "user_id": 999
    },
    "request_id": "req-abc123"  -- For debugging
  }
}

Implement error handler:

class APIException(Exception):
    def __init__(self, code, message, status_code=400, details=None):
        self.code = code
        self.message = message
        self.status_code = status_code
        self.details = details or {}

@app.errorhandler(APIException)
def handle_api_exception(e):
    return {
        "error": {
            "code": e.code,
            "message": e.message,
            "details": e.details,
            "request_id": request.headers.get('X-Request-ID', 'unknown')
        }
    }, e.status_code

@app.route('/api/v1/users/<int:user_id>')
def get_user(user_id):
    user = User.query.get(user_id)
    if not user:
        raise APIException(
            code="USER_NOT_FOUND",
            message=f"User {user_id} not found",
            status_code=404,
            details={"user_id": user_id}
        )
    return user.to_dict()

5. Rate Limiting#

Prevent abuse:

from flask_limiter import Limiter

limiter = Limiter(key_func=lambda: request.headers.get('X-User-ID'))

@app.route('/api/v1/orders', methods=['POST'])
@limiter.limit("10 per minute")  # Max 10 orders/minute per user
def create_order():
    order = Order.create(request.json)
    return {"id": order.id}, 201

Response headers:

X-RateLimit-Limit: 10
X-RateLimit-Remaining: 7
X-RateLimit-Reset: 1705431300

Client can check remaining quota before hitting limit.

6. Authentication & Authorization#

Use API keys or OAuth:

def require_auth(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        token = request.headers.get('Authorization', '').replace('Bearer ', '')
        if not token:
            raise APIException("MISSING_TOKEN", "Authorization header required", 401)
        
        # Verify token
        user = User.verify_token(token)
        if not user:
            raise APIException("INVALID_TOKEN", "Token expired or invalid", 401)
        
        request.user = user
        return f(*args, **kwargs)
    return decorated

@app.route('/api/v1/orders', methods=['GET'])
@require_auth
def get_orders():
    # request.user is set
    orders = Order.query.filter_by(user_id=request.user.id).all()
    return {"data": [o.to_dict() for o in orders]}

7. Idempotency#

Safe to retry without side effects:

# Client sends idempotency key
POST /api/v1/payments
X-Idempotency-Key: abc123-def456

# Server stores result
@app.route('/api/v1/payments', methods=['POST'])
def create_payment():
    idempotency_key = request.headers.get('X-Idempotency-Key')
    
    # Check if already processed
    existing = Payment.query.filter_by(
        idempotency_key=idempotency_key
    ).first()
    if existing:
        return existing.to_dict(), 200
    
    # Process payment
    payment = Payment.create(idempotency_key=idempotency_key, **request.json)
    return payment.to_dict(), 201

Allows safe retries on network failures.

8. Documentation (OpenAPI/Swagger)#

openapi: 3.0.0
info:
  title: My API
  version: 1.0.0

paths:
  /api/v1/users:
    get:
      summary: List users
      parameters:
        - name: offset
          in: query
          schema:
            type: integer
        - name: limit
          in: query
          schema:
            type: integer
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                  pagination:
                    type: object

    post:
      summary: Create user
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                email:
                  type: string
      responses:
        '201':
          description: Created

Tools like Swagger UI generate interactive documentation.

Checklist#

  • Use URL versioning (explicit)
  • Implement cursor-based pagination (scalable)
  • Standard error format with error codes
  • Rate limiting per user/IP
  • Authentication (API key or OAuth)
  • Idempotency for critical operations (payments)
  • Proper HTTP status codes (200, 201, 400, 401, 404, 429)
  • OpenAPI/Swagger documentation
  • CORS headers (if cross-origin)
  • Request ID for tracing

Conclusion#

Good API design pays dividends. Clients can scale. Breaking changes are rare. Debugging is easier.

Start with versioning, pagination, and error handling. Everything else builds naturally.


Designing or auditing an API that needs to scale past its first version? Book a 15-minute call.

FAQ

What's the most common REST API versioning mistake?

Skipping versioning entirely, then breaking every client on the first schema change. Version from day one (URL path or header), even with a single consumer.

Should I use offset or cursor pagination?

Cursor pagination for anything that scales past a few thousand rows — offset pagination degrades and can skip or duplicate rows when data changes between pages.

How much does bad API design actually cost?

It shows up as client-side workarounds, support tickets for undocumented error codes, and rewrites when breaking changes ship without a version bump — cheaper to design it right once than patch it under load.

About the author

Subid Dasis a Forward Deployed Engineer & Systems Architect building high-reliability cloud platforms, AI automations, and custom business software. Explore more technical articles on the blog, or get in touch if you're working on something similar.

Recommended Reading

Continue exploring production engineering guides, systems architecture, and cloud patterns.

Let's
build.

Tell me what’s slowing your business down. We’ll work out what to build next.

Tell me about your project

A few sentences are enough to start. No technical brief needed.

Service, budget & timeline (optional) +
I need help with
Budget (optional)
Timeline (optional)
LET'S CONNECT