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.

