Flask RESTful API — Deep Dive
API versioning strategies
APIs evolve. Clients depend on your current contract. Breaking changes need versioning.
URL prefix versioning
v1 = Blueprint('api_v1', __name__, url_prefix='/api/v1')
v2 = Blueprint('api_v2', __name__, url_prefix='/api/v2')
@v1.route('/users')
def list_users_v1():
users = User.query.all()
return jsonify([serialize_user_v1(u) for u in users])
@v2.route('/users')
def list_users_v2():
users = User.query.all()
return jsonify({
'data': [serialize_user_v2(u) for u in users],
'meta': {'version': 2}
})
Pros: Explicit, easy to route, clients see the version in the URL. Cons: Code duplication between versions.
Header-based versioning
@app.route('/api/users')
def list_users():
version = request.headers.get('Accept-Version', '1')
if version == '2':
return list_users_v2_impl()
return list_users_v1_impl()
Pros: Clean URLs, single route definition. Cons: Harder to test, not visible in browser, easy to forget the header.
Practical recommendation
Use URL versioning for major breaking changes and additive changes (new fields) without versioning. Most real-world APIs rarely need more than v1 and v2.
Serialization with Marshmallow
Manual jsonify dictionaries become unmaintainable at scale. Marshmallow provides schema-based serialization and deserialization:
from marshmallow import Schema, fields, validate, post_load
class UserSchema(Schema):
id = fields.Int(dump_only=True)
email = fields.Email(required=True)
name = fields.Str(required=True, validate=validate.Length(min=2, max=100))
created_at = fields.DateTime(dump_only=True)
role = fields.Str(validate=validate.OneOf(['user', 'admin']))
@post_load
def make_user(self, data, **kwargs):
return User(**data)
user_schema = UserSchema()
users_schema = UserSchema(many=True)
Usage in views:
@app.route('/api/users', methods=['GET'])
def list_users():
users = User.query.all()
return jsonify(users_schema.dump(users))
@app.route('/api/users', methods=['POST'])
def create_user():
try:
user = user_schema.load(request.get_json())
except ValidationError as e:
return jsonify({'errors': e.messages}), 422
db.session.add(user)
db.session.commit()
return jsonify(user_schema.dump(user)), 201
Marshmallow schemas serve as both serializer (Python → JSON) and deserializer (JSON → Python), with validation built in. The dump_only and load_only parameters control which direction each field works in.
Nested schemas
class PostSchema(Schema):
id = fields.Int(dump_only=True)
title = fields.Str(required=True)
author = fields.Nested(UserSchema, dump_only=True)
tags = fields.List(fields.Str())
Nested schemas handle relationship serialization cleanly, avoiding the manual dictionary nesting that gets messy in larger APIs.
Pagination implementation
Offset-based pagination
@app.route('/api/users')
def list_users():
page = request.args.get('page', 1, type=int)
per_page = min(request.args.get('per_page', 20, type=int), 100)
pagination = User.query.order_by(User.created_at.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
return jsonify({
'data': users_schema.dump(pagination.items),
'meta': {
'page': pagination.page,
'per_page': pagination.per_page,
'total': pagination.total,
'pages': pagination.pages,
},
'links': {
'self': url_for('list_users', page=page, per_page=per_page),
'next': url_for('list_users', page=page+1, per_page=per_page) if pagination.has_next else None,
'prev': url_for('list_users', page=page-1, per_page=per_page) if pagination.has_prev else None,
}
})
Limitation: Offset pagination degrades with large datasets. OFFSET 100000 requires the database to scan and skip 100,000 rows.
Cursor-based pagination
@app.route('/api/users')
def list_users():
per_page = min(request.args.get('per_page', 20, type=int), 100)
cursor = request.args.get('cursor') # Last seen user ID
query = User.query.order_by(User.id.desc())
if cursor:
query = query.filter(User.id < int(cursor))
users = query.limit(per_page + 1).all() # Fetch one extra to detect "has more"
has_more = len(users) > per_page
users = users[:per_page]
return jsonify({
'data': users_schema.dump(users),
'meta': {'has_more': has_more},
'links': {
'next': url_for('list_users', cursor=users[-1].id, per_page=per_page) if has_more else None
}
})
Cursor pagination uses an indexed column (usually the primary key) to skip efficiently. The tradeoff: no total count, no “jump to page 5,” and the cursor value must be from an indexed, unique, sequential column.
Rate limiting
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
limiter = Limiter(
app=app,
key_func=get_remote_address,
default_limits=["200 per day", "50 per hour"],
storage_uri="redis://localhost:6379",
)
@app.route('/api/users', methods=['POST'])
@limiter.limit("5 per minute")
def create_user():
# ...
@app.route('/api/search')
@limiter.limit("30 per minute")
def search():
# ...
Rate limiting per-endpoint prevents abuse. Use Redis storage for distributed setups (multiple workers). Return 429 Too Many Requests with a Retry-After header:
@app.errorhandler(429)
def ratelimit_handler(e):
return jsonify({
'error': {
'code': 'RATE_LIMIT_EXCEEDED',
'message': f'Rate limit exceeded: {e.description}',
'retry_after': e.retry_after
}
}), 429
Content negotiation
Support multiple response formats when needed:
@app.route('/api/users')
def list_users():
users = User.query.all()
if request.accept_mimetypes.best == 'text/csv':
csv_data = 'id,name,email\n'
csv_data += '\n'.join(f'{u.id},{u.name},{u.email}' for u in users)
return Response(csv_data, mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=users.csv'})
return jsonify(users_schema.dump(users))
Check request.accept_mimetypes to determine what the client wants. Default to JSON when the Accept header is absent or ambiguous.
HATEOAS: links in responses
Hypermedia as the Engine of Application State means responses include links to related actions:
def serialize_user_with_links(user):
data = user_schema.dump(user)
data['_links'] = {
'self': url_for('get_user', user_id=user.id, _external=True),
'posts': url_for('list_user_posts', user_id=user.id, _external=True),
'update': url_for('update_user', user_id=user.id, _external=True),
'delete': url_for('delete_user', user_id=user.id, _external=True),
}
return data
HATEOAS makes APIs discoverable — clients follow links instead of constructing URLs. In practice, most APIs implement partial HATEOAS (pagination links, self links) rather than full hypermedia.
OpenAPI documentation
Auto-generate API docs with flask-smorest or apispec:
from flask_smorest import Api, Blueprint as SmorestBlueprint
api = Api(app)
blp = SmorestBlueprint('users', __name__, url_prefix='/api/users')
@blp.route('/')
class UserList(MethodView):
@blp.response(200, UserSchema(many=True))
def get(self):
"""List all users."""
return User.query.all()
@blp.arguments(UserSchema)
@blp.response(201, UserSchema)
def post(self, user_data):
"""Create a new user."""
user = User(**user_data)
db.session.add(user)
db.session.commit()
return user
api.register_blueprint(blp)
This generates an OpenAPI 3.0 spec at /api/openapi.json and serves Swagger UI at /api/docs. The schemas (Marshmallow) double as both validation and documentation, keeping them in sync.
Error handling architecture
A centralized error handler converts all exceptions to consistent JSON:
class APIError(Exception):
def __init__(self, message, code, status=400, details=None):
self.message = message
self.code = code
self.status = status
self.details = details
@app.errorhandler(APIError)
def handle_api_error(e):
response = {
'error': {
'code': e.code,
'message': e.message,
}
}
if e.details:
response['error']['details'] = e.details
return jsonify(response), e.status
# Usage
@app.route('/api/users/<int:user_id>')
def get_user(user_id):
user = User.query.get(user_id)
if not user:
raise APIError('User not found', 'USER_NOT_FOUND', 404)
return jsonify(user_schema.dump(user))
Custom exception classes with error codes let clients handle errors programmatically. The string USER_NOT_FOUND is stable across API versions; the human-readable message can change.
Request/response middleware
Add cross-cutting concerns with before/after hooks:
@app.before_request
def log_request():
request.start_time = time.monotonic()
request_id = request.headers.get('X-Request-ID', str(uuid4()))
g.request_id = request_id
@app.after_request
def add_headers(response):
response.headers['X-Request-ID'] = g.get('request_id', '')
elapsed = time.monotonic() - getattr(request, 'start_time', time.monotonic())
response.headers['X-Response-Time'] = f'{elapsed:.3f}s'
# Log for monitoring
app.logger.info(f'{request.method} {request.path} → {response.status_code} ({elapsed:.3f}s)')
return response
Request IDs enable distributed tracing. Response time headers help clients detect slow endpoints.
One thing to remember: A production Flask API is defined by its contract — URL structure, status codes, response shapes, error formats. The internal implementation can change freely; the contract is what clients depend on. Invest in consistent serialization (Marshmallow), documentation (OpenAPI), and error handling before optimizing code.
See Also
- Python Django Admin Get an intuitive feel for Django Admin so Python behavior stops feeling unpredictable.
- Python Django Basics Get an intuitive feel for Django Basics so Python behavior stops feeling unpredictable.
- Python Django Celery Integration Why your Django app needs a helper to handle slow jobs in the background.
- Python Django Channels Websockets How Django can send real-time updates to your browser without you refreshing the page.
- Python Django Custom Management Commands How to teach Django new tricks by creating your own command-line shortcuts.