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
3. Filtering & Search#
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.
RelatedArticles
Continue exploring production engineering guides, systems architecture, and cloud patterns.


