Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 087 / 100

Python performance tips that matter: a step-by-step guide

Real wins in real backends.

Expona Admin7 min read

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:

python

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:

python

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:

python

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:

python

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.

python

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:

python

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:

python

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:

python

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):

python

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):

python

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:

python

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 cProfile and 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 json with orjson for 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.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles