Python performance tips that matter: a step-by-step guide
When your backend starts handling real traffic, you discover that clean code alone doesn't guarantee fast code. We've all deployed a service that works fine in staging, then watched response times climb as load increases. This guide walks through practical performance improvements we've made in production backends—the ones that actually move the needle.
Fix database queries first
Database performance dominates backend latency in most applications. Before you optimize anywhere else, look at your queries. Missing indexes, N+1 problems, and unnecessary columns will tank your throughput far more than any Python code optimization can fix.
Let's say you're returning user profiles with their recent orders:
def get_user_with_orders(user_id):
user = User.query.get(user_id)
# This triggers a query per user
orders = Order.query.filter_by(user_id=user_id).all()
return {
'id': user.id,
'name': user.name,
'orders': [{'id': o.id, 'total': o.total} for o in orders]
}
If you call this 100 times in a request loop, you've just made 101 database queries. The fix:
def get_user_with_orders(user_id):
# Eager load orders with the user in a single query
user = User.query.options(
db.joinedload(User.orders)
).filter_by(id=user_id).first()
if not user:
return None
return {
'id': user.id,
'name': user.name,
'orders': [{'id': o.id, 'total': o.total} for o in user.orders]
}
When fetching multiple users at once, use selectinload to batch the relationship queries:
def get_users_with_orders(user_ids):
users = User.query.options(
db.selectinload(User.orders)
).filter(User.id.in_(user_ids)).all()
return [{
'id': u.id,
'name': u.name,
'orders': [{'id': o.id, 'total': o.total} for o in u.orders]
} for u in users]
Add indexes on frequently filtered columns. If you're querying Order.user_id constantly, ensure it's indexed. Check your slow query logs regularly—they reveal the real bottlenecks in your system.
Cache expensive computations
Some calculations are genuinely expensive: aggregations across large datasets, calls to external APIs, or complex business logic. Computing them on every request wastes cycles. Caching with an appropriate TTL is a straightforward win.
Suppose you calculate monthly revenue per customer segment:
import time
from functools import wraps
import redis
redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True)
def cached(ttl_seconds=300):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# Build a cache key from function name and arguments
cache_key = f"{func.__name__}:{str(args)}:{str(kwargs)}"
# Try to get from cache
cached_result = redis_client.get(cache_key)
if cached_result:
return eval(cached_result) # Use json for production
# Compute and cache
result = func(*args, **kwargs)
redis_client.setex(cache_key, ttl_seconds, str(result))
return result
return wrapper
return decorator
@cached(ttl_seconds=3600)
def get_revenue_by_segment(month_year):
# Expensive aggregation query
segments = db.session.query(
Customer.segment,
func.sum(Order.total).label('revenue')
).join(Order).filter(
func.date_trunc('month', Order.created_at) == month_year
).group_by(Customer.segment).all()
return {seg: revenue for seg, revenue in segments}
For cached data that updates frequently, consider a hybrid approach: compute in the background and refresh periodically rather than on demand.
from celery import shared_task
@shared_task
def refresh_revenue_cache():
current_month = datetime.now().replace(day=1)
result = get_revenue_by_segment.__wrapped__(current_month)
redis_client.setex(
f"get_revenue_by_segment:({current_month},):{{}}",
3600,
str(result)
)
# Schedule this task to run every hour with Celery Beat
Be deliberate about what you cache. Cache reads more aggressively than writes. Invalidate strategically when underlying data changes—don't let stale data mislead users.
Use orjson for faster JSON
Python's built-in json module is mature but not optimized for speed. The orjson library is significantly faster because it's built in Rust and handles serialization more efficiently.
Standard approach:
import json
from flask import Flask, jsonify
app = Flask(__name__)
@app.route('/users/')
def get_user(user_id):
user = User.query.get(user_id)
return jsonify({
'id': user.id,
'name': user.name,
'email': user.email,
'created_at': user.created_at.isoformat()
})
With orjson:
import orjson
from flask import Flask, Response
app = Flask(__name__)
@app.route('/users/')
def get_user(user_id):
user = User.query.get(user_id)
data = {
'id': user.id,
'name': user.name,
'email': user.email,
'created_at': user.created_at.isoformat()
}
return Response(
orjson.dumps(data),
mimetype='application/json'
)
Or integrate it at the Flask level for all responses:
app.json.encoder = orjson
# For newer Flask versions, configure the JSON provider
class ORJSONProvider:
def dumps(self, obj, **kwargs):
return orjson.dumps(obj)
def loads(self, s, **kwargs):
return orjson.loads(s)
app.json = ORJSONProvider(app)
In benchmarks across our services, switching to orjson typically reduces JSON serialization time by 40-60%. For APIs returning large payloads—especially lists—this adds up quickly.
Batch external calls
Making one HTTP request per item is a performance killer. When you need data from an external service for multiple records, batch them into fewer requests.
Inefficient pattern (N requests):
def enrich_orders(order_ids):
results = []
for order_id in order_ids:
order = Order.query.get(order_id)
# One request per order
pricing = requests.get(
f'https://pricing-service/products/{order.product_id}'
).json()
results.append({
'order_id': order.id,
'price': pricing['current_price']
})
return results
Better approach (single batched request):
def enrich_orders(order_ids):
orders = Order.query.filter(Order.id.in_(order_ids)).all()
product_ids = [o.product_id for o in orders]
# Single request with all product IDs
pricing_response = requests.post(
'https://pricing-service/batch-pricing',
json={'product_ids': product_ids},
timeout=5
).json()
pricing_by_product = {p['id']: p['current_price'] for p in pricing_response['prices']}
return [{
'order_id': o.id,
'price': pricing_by_product.get(o.product_id)
} for o in orders]
If the external service doesn't support batch endpoints, implement a client-side solution with futures or async/await:
import asyncio
import aiohttp
async def get_pricing_async(product_id, session):
async with session.get(
f'https://pricing-service/products/{product_id}'
) as resp:
return await resp.json()
async def enrich_orders_async(order_ids):
orders = Order.query.filter(Order.id.in_(order_ids)).all()
async with aiohttp.ClientSession() as session:
tasks = [
get_pricing_async(o.product_id, session)
for o in orders
]
pricing_results = await asyncio.gather(*tasks, return_exceptions=True)
results = []
for order, pricing in zip(orders, pricing_results):
if isinstance(pricing, dict):
results.append({
'order_id': order.id,
'price': pricing.get('current_price')
})
return results
Set reasonable timeouts. A 30-second request waiting for an external API degrades your entire service. Circuit breakers and fallbacks protect you when external dependencies slow down.
Common mistakes
- Optimizing before measuring: Profile your code first. Use tools like
cProfileand database slow query logs to find real bottlenecks. Premature optimization wastes time on the wrong problems. - Caching without invalidation strategy: Cached data that never updates is eventually wrong data. Know when and how to clear caches when underlying data changes.
- Ignoring connection pooling: Creating a new database or Redis connection per request kills performance. Always use connection pools configured for your concurrency model.
- Assuming all external APIs are reliable: Network calls fail. Missing timeout configuration or retry logic will hang requests indefinitely and cascade through your system.
- Batching requests without size limits: A 10,000-item batch request might overwhelm downstream services. Implement sensible batch size caps and iterate if needed.
Key takeaways
- Database queries are almost always the biggest performance lever—fix N+1 problems and add indexes before optimizing Python code.
- Cache expensive computations with appropriate TTLs, but plan your invalidation strategy from the start.
- Replace
jsonwithorjsonfor consistent 40-60% serialization speedup across response-heavy APIs. - Batch external service calls into fewer requests, or use async patterns to parallelize them; always set timeouts and handle failures gracefully.
- Measure before you optimize; use profilers and slow query logs to guide where effort pays off.






Comments
Loading comments…