Filter concepts by levelShowing all levels.

Python · Performance

Optimization

Concepts
4

Caching and lazy evaluation to avoid unnecessary work, batching/connection pooling/query optimization to reduce round trips, parallelization/async I/O/serialization optimization for the remaining techniques, and the closing discipline that ties them all together: measure first, optimize second.

This section

Techniques and the discipline behind them

The concrete optimization techniques the roadmap lists, and the one rule that decides whether applying any of them was worth the effort.

Caching and lazy evaluation

coreintermediate

@functools.lru_cache remembers a function's past results, so calling it again with the same arguments returns instantly. Lazy evaluation delays work until actually needed — sometimes it never is, avoiding it entirely.

Think of it as

Caching is writing an answer on a sticky note the first time you work it out, so next time you just read the note. Lazy evaluation is not doing the work at all until someone actually asks for the answer — sometimes nobody ever does, and the work is avoided completely, not just sped up.

python
from functools import lru_cache

@lru_cache(maxsize=None)
def expensive_computation(x):
    ...

What we're doing: Measure a real speedup from lru_cache on recursive Fibonacci, where naive recursion recomputes the same values exponentially many times.

lru_cache_speedup.pypython
import time
from functools import lru_cache

def fib_uncached(n):
    if n < 2:
        return n
    return fib_uncached(n - 1) + fib_uncached(n - 2)

@lru_cache(maxsize=None)
def fib_cached(n):
    if n < 2:
        return n
    return fib_cached(n - 1) + fib_cached(n - 2)

start = time.perf_counter()
fib_uncached(28)
t1 = time.perf_counter() - start

start = time.perf_counter()
fib_cached(28)
t2 = time.perf_counter() - start

print(f"uncached: {t1:.4f}s")
print(f"cached:   {t2:.6f}s")
print(f"speedup: {t1 / t2:.0f}x")
1
The uncached version recomputes fib(n-2) millions of times across the recursive call tree — genuinely exponential work.
3
@lru_cache means every distinct n is computed exactly once — the second call with the same n returns instantly from the cache.
Output
uncached: 0.0648s
cached:   0.000175s
speedup: 370x

Why this works: Naive recursive Fibonacci recomputes the same sub-values an exponential number of times — fib(28) alone makes over a million redundant calls. Caching turns it into linear work: each distinct n computed exactly once, which is the direct, measured source of the 370x speedup.

Caching a function with mutable arguments or side effects

Wrong

python
@lru_cache(maxsize=None)
def get_user_config(user_id):
    return fetch_from_database(user_id)   # returns STALE data forever after first call

Better

python
@lru_cache(maxsize=None)
def get_static_config(config_name):
    return load_from_file(config_name)   # genuinely never changes -- safe to cache

# for data that CAN change, cache with an explicit expiry instead

What you see: A cached function keeps returning the same result forever, even after the underlying data (a database row, a file) has actually changed.

Why: lru_cache has no concept of "this might be stale" — it caches forever (or until eviction) based purely on the arguments. It is only safe for genuinely pure, unchanging computations, not for anything backed by data that can change after the first call.

Caching turns exponential re-work into linear work
yesno

fib_cached(28)

n already in cache?

return cached result

instant — 0.000175s total

compute fib(n-1) + fib(n-2)

once per distinct n only

store in cache, return

  • fib_cached(28)
    • leads to n already in cache?
  • n already in cache?
    • leads to return cached result (yes)
    • leads to compute fib(n-1) + fib(n-2) (no)
  • return cached result — instant — 0.000175s total
  • compute fib(n-1) + fib(n-2) — once per distinct n only
    • leads to store in cache, return
  • store in cache, return

lru_cache — the interface worth knowing

lru_cache — the interface worth knowing
CallEffect
@lru_cache(maxsize=None)unbounded cache — every distinct call is remembered forever
@lru_cache(maxsize=128)bounded cache — oldest unused entries evicted once full
fn.cache_info()reports hits, misses, and current cache size
fn.cache_clear()empties the cache manually

Together

python
from functools import lru_cache

@lru_cache(maxsize=None)
def fib(n):
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

print(fib(30))
print(fib.cache_info())

Remember: @lru_cache remembers past results for pure functions but never detects staleness; lazy evaluation avoids unneeded work.

See also: batching and connection pooling · functools module · lazy evaluation

Batching, connection pooling, and query optimization

coreintermediate

Batching combines many small operations into fewer, larger ones. Connection pooling reuses already-open connections instead of opening a new one per request. Query optimization means a query (and its indexes) doing less work to answer.

Think of it as

Making 100 separate database round trips is like driving to the store 100 times for one item each — batching is making one trip with a full cart. Connection pooling is keeping a car running in the driveway instead of building a new one for every errand.

python
# batched query, one round trip instead of N
users = db.query("SELECT * FROM users WHERE id IN (?)", user_ids)

# connection pool, reused across requests
pool = create_pool(min_size=5, max_size=20)
async with pool.acquire() as conn:
    await conn.fetch(...)

What we're doing: Simulate the round-trip cost difference between N separate calls and one batched call, using a fixed per-call overhead to make the pattern concrete.

batching_simulation.pypython
import time

ROUND_TRIP_OVERHEAD = 0.001   # simulated fixed cost per network call

def fetch_one(item_id):
    time.sleep(ROUND_TRIP_OVERHEAD)   # stands in for a real network round trip
    return {"id": item_id}

def fetch_batched(item_ids):
    time.sleep(ROUND_TRIP_OVERHEAD)   # ONE round trip, regardless of batch size
    return [{"id": i} for i in item_ids]

item_ids = list(range(100))

start = time.perf_counter()
for item_id in item_ids:
    fetch_one(item_id)
t_unbatched = time.perf_counter() - start

start = time.perf_counter()
fetch_batched(item_ids)
t_batched = time.perf_counter() - start

print(f"unbatched (100 calls): {t_unbatched:.3f}s")
print(f"batched (1 call):      {t_batched:.3f}s")
print(f"batched is {t_unbatched / t_batched:.0f}x faster")
5
Each call to fetch_one pays the fixed round-trip cost individually — 100 items means 100x that cost.
9
fetch_batched pays the SAME fixed cost only ONCE, regardless of how many items are in the batch.
Output
unbatched (100 calls): 0.175s
batched (1 call):      0.002s
batched is 83x faster

Why this works: The simulated round-trip overhead is paid 100 times in the unbatched version and exactly once in the batched version — this is the same fixed-cost-per-call shape a real database's N+1 query problem has, just made concrete with a stand-in delay instead of a real network call.

The N+1 query problem, hidden inside an ORM loop

Wrong

python
orders = Order.objects.all()   # 1 query
for order in orders:
    print(order.customer.name)   # a SEPARATE query, EVERY iteration -- N+1

Better

python
orders = Order.objects.select_related("customer")   # 1 query, JOINs customer
for order in orders:
    print(order.customer.name)   # no extra query -- already loaded

What you see: A page or endpoint that looks like it does "one query" in the code actually issues hundreds against the database, visible only in a query log or slow-request profile.

Why: Accessing a related object (order.customer) inside a loop triggers a lazy lookup PER iteration unless the ORM was told to fetch it eagerly (select_related/prefetch_related) up front — the N+1 problem is invisible in the Python code itself, only visible in the actual query count.

N round trips vs. one batched round trip

N+1 (unbatched)

  • +One query PER item, inside a loop
  • +100 items = 100 round trips
  • +Fixed per-call overhead paid every time

Batched (WHERE id IN (...))

  • One query for every item at once
  • 100 items = 1 round trip
  • Fixed overhead paid exactly once
  • N+1 (unbatched)
    • One query PER item, inside a loop
    • 100 items = 100 round trips
    • Fixed per-call overhead paid every time
  • Batched (WHERE id IN (...))
    • One query for every item at once
    • 100 items = 1 round trip
    • Fixed overhead paid exactly once

Batched vs. unbatched, by round trips

Batched vs. unbatched, by round trips
ApproachRound trips for 100 items
One query per item (N+1)100 separate round trips
One batched query1 round trip, WHERE id IN (...)
No connection poolconnection handshake cost paid on every request
Connection poolhandshake cost paid once, connections reused

Together

python
# N+1 -- one query PER user, inside a loop
for user_id in user_ids:
    user = db.query("SELECT * FROM users WHERE id = ?", user_id)

# batched -- ONE query for all of them
users = db.query("SELECT * FROM users WHERE id IN (?)", user_ids)

Remember: Batching combines many round trips into fewer, larger ones; watch for the N+1 pattern hiding inside a loop.

See also: caching and lazy evaluation · parallelization and async io

Parallelization, async I/O, and serialization optimization

coreintermediate

Parallelization runs independent work across CPU cores at once. Async I/O overlaps many waits on one thread instead of one at a time. Serialization optimization means a faster format or less data — json can be a real cost at scale.

Think of it as

Serial work is one cashier serving customers one at a time. Parallelization is opening several checkout lanes with several cashiers, for genuinely independent work. Async I/O is one cashier serving multiple customers by starting the next one's order while the first one's payment processes — one lane, but no idle waiting.

python
from concurrent.futures import ProcessPoolExecutor
with ProcessPoolExecutor() as ex:
    results = list(ex.map(cpu_heavy_function, items))

import json
data = json.dumps(obj)   # standard library, simple, not the fastest option

What we're doing: Measure the real cost of json.dumps at scale, showing serialization is a genuine, measurable expense, not free.

serialization_cost.pypython
import json
import time

records = [{"id": i, "name": f"user_{i}", "active": True} for i in range(100_000)]

start = time.perf_counter()
serialized = json.dumps(records)
elapsed = time.perf_counter() - start

print(f"serialized {len(records)} records in {elapsed:.4f}s")
print(f"output size: {len(serialized) / 1024:.1f} KB")
4
100,000 small dicts is a realistic size for a real API response or batch export.
6
json.dumps is doing real work here — walking every record and converting Python types to their JSON text representation.
Output
serialized 100000 records in 0.0619s
output size: 5154.1 KB

Why this works: json.dumps genuinely spends measurable time (tens of milliseconds here) converting 100,000 Python dicts into a 4+ MB JSON string — at high request volume, or with larger payloads, this cost compounds into a real, profileable line item, not a rounding error.

Serializing an entire object when only a few fields are needed

Wrong

python
def get_user_summary(user):
    return json.dumps(user.__dict__)   # serializes EVERY field, including large/unused ones

Better

python
def get_user_summary(user):
    return json.dumps({"id": user.id, "name": user.name})   # only what the caller needs

What you see: A response payload is far larger than what the caller actually uses, and serialization time scales with data that never gets read.

Why: Serialization cost scales with the AMOUNT of data converted, not how much of it is useful — sending only the fields a caller actually needs is often a bigger win than optimizing the serializer itself.

ProcessPoolExecutor vs. asyncio.gather, on I/O-bound work

ProcessPoolExecutor (wrong fit here)

  • +Real spawn + pickling overhead per process
  • +Measured 0.454s — SLOWER than serial (0.405s)
  • +Right for CPU-bound work, not for waiting

asyncio.gather (right fit)

  • Overlaps the same waits on one thread
  • No process-spawn overhead at all
  • Right for many concurrent I/O-bound waits
  • ProcessPoolExecutor (wrong fit here)
    • Real spawn + pickling overhead per process
    • Measured 0.454s — SLOWER than serial (0.405s)
    • Right for CPU-bound work, not for waiting
  • asyncio.gather (right fit)
    • Overlaps the same waits on one thread
    • No process-spawn overhead at all
    • Right for many concurrent I/O-bound waits

Choosing between parallelization and async I/O

Choosing between parallelization and async I/O
WorkloadFits
CPU-bound (heavy computation)multiprocessing (ProcessPoolExecutor)
I/O-bound, many concurrent waitsasyncio
I/O-bound, few concurrent waits, sync librariesthreading

Together

python
# CPU-bound -- real parallelism across cores
with ProcessPoolExecutor() as ex:
    results = list(ex.map(cpu_heavy_function, data_chunks))

# I/O-bound -- overlap many waits on one thread
async with httpx.AsyncClient() as client:
    results = await asyncio.gather(*(client.get(u) for u in urls))

Remember: Parallelization uses multiple cores for CPU-bound work; async I/O overlaps waits for I/O-bound work; serialization is not free.

See also: concurrency approaches overview · choosing threading multiprocessing or asyncio · batching and connection pooling

Measure first, optimize second

coreintermediate

Intuition about what is slow in a program is frequently wrong — profile with a real tool (cProfile, timeit) before spending effort optimizing, or the effort often lands on something that was never the actual bottleneck.

Think of it as

Optimizing without profiling is fixing a car by replacing parts at random, hoping one of them was the problem — expensive and often wrong. Profiling first is running a diagnostic to find the ACTUAL faulty part before touching anything.

python
# 1. Profile first
python -m cProfile -s cumulative myapp.py

# 2. Find what ACTUALLY dominates runtime (top of the sorted list)
# 3. Optimize THAT, specifically
# 4. Re-profile to confirm the fix actually helped

What we're doing: Profile a program with a deliberately misleading structure — an innocent-looking call that is actually the real cost — and show cProfile catching what intuition would likely miss.

measure_first.pypython
import cProfile
import pstats
import time

def looks_complex_but_is_cheap():
    return sum(i * i for i in range(1000))

def looks_simple_but_is_expensive():
    time.sleep(0.1)   # stands in for a real network call
    return "config loaded"

def main():
    for _ in range(3):
        looks_complex_but_is_cheap()
    looks_simple_but_is_expensive()

cProfile.run("main()", "measure_first.prof")
stats = pstats.Stats("measure_first.prof")
stats.sort_stats("cumulative")
stats.print_stats("measure_first")   # filter to just this file's own functions
5
This function has a comprehension inside a generator — it LOOKS like the interesting, complex part of the program.
8
This function looks trivial — one line — but is actually the genuine cost, standing in for a real network call.
Output
   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    0.101    0.101 measure_first.py:12(main)
        1    0.000    0.000    0.100    0.100 measure_first.py:8(looks_simple_but_is_expensive)
        3    0.000    0.000    0.001    0.000 measure_first.py:5(looks_complex_but_is_cheap)
     3003    0.000    0.000    0.000    0.000 measure_first.py:6(<genexpr>)

Why this works: looks_simple_but_is_expensive shows cumtime 0.100 — nearly the entire program's runtime — while looks_complex_but_is_cheap, despite its nested generator expression running 3,003 times, shows cumtime only 0.001. The function with visually complex code barely registers; the visually trivial one dominates — exactly the mismatch between "looks complicated" and "actually costs time" that makes guessing unreliable and profiling necessary.

Optimizing the function that "feels" slow instead of the one the profiler names

Wrong

python
# spent a day rewriting the "complex-looking" comprehension for speed
def looks_complex_but_is_cheap():
    # heavily optimized... but it was never the bottleneck

Better

python
# profile FIRST, find the ACTUAL top entry by cumtime,
# THEN spend the day optimizing that one specifically

What you see: A significant optimization effort produces no measurable improvement in overall application performance.

Why: Time spent optimizing a function that was never the actual bottleneck cannot improve total runtime, no matter how much faster that one function gets — the profiler's job is to point at the RIGHT target before any optimization effort begins.

Looks complex vs. actually costs time

Intuition: "the loop is slow"

cProfile.run("main()")

looks_complex_but_is_cheap()

cumtime 0.001s

looks_simple_but_is_expensive()

cumtime 0.100s — the real cost

  • Intuition: "the loop is slow"
    • leads to cProfile.run("main()")
  • cProfile.run("main()")
    • leads to looks_complex_but_is_cheap()
    • leads to looks_simple_but_is_expensive()
  • looks_complex_but_is_cheap() — cumtime 0.001s
  • looks_simple_but_is_expensive() — cumtime 0.100s — the real cost

Guessed vs. measured — a real contrast

Guessed vs. measured — a real contrast
BeliefWhat profiling often reveals
"The nested loop must be the bottleneck"Sometimes it is 1% of runtime; the real cost is a database call
"This function looks complex, it must be slow"Complexity of CODE and complexity of RUNTIME COST are different things
"I optimized the algorithm, it must be faster now"Sometimes the real cost was I/O, untouched by the optimization

Together

python
# guessed bottleneck: this LOOKS complex
def parse_config(raw):
    return {k.strip(): v.strip() for k, v in (line.split("=") for line in raw.splitlines())}

# actual measured bottleneck, found via cProfile: this innocent-looking call
def load_config():
    return requests.get(CONFIG_URL).text   # network round trip, 200ms+

Remember: Intuition about what is slow is frequently wrong — profile first, optimize the actual bottleneck, then re-profile.

See also: cprofile and pstats · timeit and microbenchmarks

Advertisement