SUBID DASPORTFOLIO / 2026
000

SUBID

PORTRAIT // ORIGINAL[ 1 / 7 ]
WORK // RIDENOWW[ 2 / 7 ]
WORK // DEED POLL[ 3 / 7 ]
PORTRAIT // CYBERNETIC[ 4 / 7 ]
PORTRAIT // HOLOGRAM[ 5 / 7 ]
PORTRAIT // SKETCH[ 6 / 7 ]
PORTRAIT // GLITCH[ 7 / 7 ]
SUBID DAS
Back to Blog

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

10 min readSubid Das
api-designbackendrestdevopsbest-practices

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.

About the author

Subid Das is a cloud native engineer and open source contributor. Find more articles onthe blog.

Open to freelance, full-time, and interesting problems.

LET'S CONNECT