Making Python Fast: Profiling & Optimization¶
What this lesson gives you
Measure before you optimize. Knowing where time actually goes — and the handful of high-leverage fixes — separates guessing from engineering.
Estimated time: 20 min read · Part: Concurrency, Parallelism & Performance
Learning objectives
- Profile Python code with
cProfile,line_profiler, andtimeitto find real bottlenecks - Apply the optimization priority order: algorithm → data structure → caching → vectorization → compiled
- Use
functools.cacheandfunctools.lru_cachefor memoization - Understand why NumPy operations are dramatically faster than Python loops
- Know when to reach for Cython, Numba, or other compilation tools
The cardinal rule of optimization is: measure first, optimize second. Developers consistently misjudge where their programs spend time — the bottleneck is rarely where intuition points. Profiling reveals the actual hotspot. Only then should you optimize — and only the hotspot, not the rest. Premature optimization wastes effort and introduces complexity without corresponding benefit.
More importantly: the highest-leverage optimization is almost always a better algorithm or data structure, not a micro-optimization of existing code. Changing O(n²) to O(n) by switching a list membership check to a set can be a 10,000× speedup at n=100k; no amount of micro-tuning makes up for the wrong algorithm.
Profiling Tools: Finding the Real Bottleneck¶
import cProfile, pstats, timeit, io
# ── timeit: precise timing for small code snippets ────────────────────────────
# timeit runs the snippet many times to get a stable measurement
# Command line (most convenient):
# python -m timeit "sum(range(1000))"
# python -m timeit -n 10000 "sorted(range(100))"
# In code:
time_sum = timeit.timeit("sum(range(1000))", number=10_000)
# Returns total seconds for 10,000 runs; divide by 10_000 for per-run time
# Compare two implementations:
time_list = timeit.timeit("[x**2 for x in range(1000)]", number=1000)
time_gen = timeit.timeit("sum(x**2 for x in range(1000))", number=1000)
# ── cProfile: full call graph profiling ──────────────────────────────────────
# Profile a function call, print top 10 functions by cumulative time
def expensive():
total = 0
for i in range(100_000):
total += len(str(i)) # lots of small operations
return total
# Method 1: one-liner
cProfile.run("expensive()")
# Method 2: capture and format
pr = cProfile.Profile()
pr.enable()
expensive()
pr.disable()
stream = io.StringIO()
stats = pstats.Stats(pr, stream=stream)
stats.sort_stats("cumulative")
stats.print_stats(10) # top 10 by cumulative time
print(stream.getvalue())
# Method 3: profile decorator
import functools
def profile(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
pr = cProfile.Profile()
pr.enable()
result = func(*args, **kwargs)
pr.disable()
pr.print_stats(sort="cumulative")
return result
return wrapper
# ── line_profiler: line-by-line timing (install: pip install line_profiler) ──
# @profile ← adds profiling; run: kernprof -l -v script.py
# def my_function():
# result = [] # Line 1: 0.001s 0.1%
# for x in data: # Line 2: 0.011s 1.1%
# result.append(x) # Line 3: 0.900s 89.9% ← HOTSPOT
# return result
| Tool | What it measures | When to use | Install |
|---|---|---|---|
timeit | Wall time of a small snippet | Comparing two implementations | stdlib |
cProfile | Time per function call (call graph) | Finding which function is the hotspot | stdlib |
line_profiler | Time per line within a function | Once cProfile identifies the function | pip |
memory_profiler | Memory usage per line | Memory leak investigation | pip |
py-spy | CPU samples of running process | Profiling production without restarts | pip |
tracemalloc | Object allocation traces | Finding where memory is allocated | stdlib (3.4+) |
The Optimization Priority Ladder¶
Optimization Priority Order (highest leverage first)
──────────────────────────────────────────────────────────────────────────
┌─────────────────────────────────────────────────────────────┐
1. │ Better algorithm / data structure │ ← 10-10,000× gain
│ O(n²) → O(n log n) → O(n) by changing the approach │
├─────────────────────────────────────────────────────────────┤
2. │ Caching / memoization │ ← 10-1000× for repeated calls
│ Avoid recomputing: @functools.cache, Redis, in-memory dict │
├─────────────────────────────────────────────────────────────┤
3. │ Vectorization with NumPy / pandas │ ← 10-100× for numeric work
│ Replace Python loops with C-level array operations │
├─────────────────────────────────────────────────────────────┤
4. │ Compiled extensions / JIT │ ← 5-50× for hotspots
│ Cython, Numba, mypyc, PyPy, C extension modules │
├─────────────────────────────────────────────────────────────┤
5. │ Micro-optimizations │ ← 2-5% gain
│ Local variable lookups, avoid global lookups, etc. │
└─────────────────────────────────────────────────────────────┘
Caching and Memoization¶
Memoization stores the result of a function call and returns the cached result for the same arguments on future calls. It's the highest-leverage optimization for functions that are: (1) pure — same inputs always produce same outputs, (2) called repeatedly with the same arguments, and (3) expensive to compute. Python's functools.cache (unbounded) and functools.lru_cache (bounded) implement this as decorators.
from functools import cache, lru_cache
import timeit
# ── functools.cache: unbounded memoization ────────────────────────────────────
@cache
def fibonacci(n: int) -> int:
"""Classic example: exponential without cache, linear with cache."""
if n <= 1: return n
return fibonacci(n-1) + fibonacci(n-2)
fibonacci(50) # computed in microseconds; without cache: 2^50 calls
# ── functools.lru_cache: bounded cache with eviction ─────────────────────────
@lru_cache(maxsize=256) # keep at most 256 results; evicts LRU on overflow
def fetch_user_profile(user_id: int) -> dict:
"""Expensive DB/API call — cache the results."""
# simulate expensive operation
import time; time.sleep(0.1)
return {"id": user_id, "name": f"User {user_id}"}
# First call: executes the function
fetch_user_profile(42) # ~0.1s
# Second call: returns cached result
fetch_user_profile(42) # ~0.0001s
print(fetch_user_profile.cache_info())
# CacheInfo(hits=1, misses=1, maxsize=256, currsize=1)
# ── Real-world: caching config or API responses ───────────────────────────────
_config_cache: dict = {}
def get_config(key: str) -> str:
"""Read config once per key, cache it."""
if key not in _config_cache:
_config_cache[key] = expensive_config_read(key)
return _config_cache[key]
# ── When NOT to cache ─────────────────────────────────────────────────────────
# - Functions with side effects (writes to DB, sends emails)
# - Functions with mutable arguments (lists, dicts — not hashable anyway)
# - Functions where the result changes over time (current time, DB state)
# - When memory is the bottleneck (unbounded cache may cause OOM)
CacheInfo(hits=1, misses=1, maxsize=256, currsize=1)
Algorithmic improvement always beats micro-optimization
Consider finding whether a number appears in a list of 100k items, done 100k times. Python loop: O(n) per check × 100k checks = O(n²) = 10 billion operations, ~10 minutes. Convert to a set first: O(1) per check × 100k checks = O(n) = 100k operations, ~0.001s. That's a 600,000× speedup from a data structure change. No amount of micro-optimization achieves that. This is why "profile first, then improve the algorithm, then vectorize, then micro-optimize" is the correct order — the first two steps are where the giant wins live.
Vectorization: NumPy and the C Loop¶
Python loops over a list pay interpreter overhead on every iteration: bytecode dispatch, object lookup, type checking, reference counting — all for each element. NumPy eliminates this by pushing the loop into precompiled C code operating on a contiguous typed array. A vectorized NumPy operation processes millions of elements with a single C function call.
import numpy as np
import timeit
n = 1_000_000
# Python loop: ~100ms for 1M elements
def python_sum_squares(data):
return sum(x * x for x in data)
data_list = list(range(n))
data_np = np.arange(n, dtype=np.float64)
# NumPy vectorized: ~1ms for 1M elements (100× faster)
def numpy_sum_squares(arr):
return np.sum(arr * arr)
t_python = timeit.timeit(lambda: python_sum_squares(data_list), number=10)
t_numpy = timeit.timeit(lambda: numpy_sum_squares(data_np), number=10)
print(f"Python: {t_python:.3f}s NumPy: {t_numpy:.3f}s "
f"speedup: {t_python/t_numpy:.0f}x")
# Python: 0.912s NumPy: 0.009s speedup: 101x
# ── Key NumPy patterns ────────────────────────────────────────────────────────
arr = np.array([1, 4, 9, 16, 25], dtype=float)
arr * 2 # element-wise: [2, 8, 18, 32, 50]
np.sqrt(arr) # element-wise sqrt: [1, 2, 3, 4, 5]
arr[arr > 10] # boolean mask: [16, 25]
arr.sum() # 55.0
arr.mean() # 11.0
np.where(arr > 10, arr, 0) # conditional: [0, 0, 0, 16, 25]
# Matrix operations
A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])
A @ B # matrix multiply: [[19, 22], [43, 50]]
A.T # transpose
Python: 0.912s NumPy: 0.009s speedup: 101x
Never iterate over a NumPy array element by element
The same principle as pandas iterrows: a Python for x in numpy_array loop boxes each element into a Python object on every iteration, completely negating the benefit of using NumPy. If you find yourself writing for x in arr:, ask whether the operation can be expressed as a vectorized operation, broadcasting, or boolean masking. 99% of the time it can. If not, consider Cython or Numba.
import cProfile
from functools import lru_cache
# STEP 1: Write clear, correct code first — don't pre-optimize
def find_common_customers(orders: list[dict], returns: list[dict]) -> list[str]:
"""Find customers who both ordered AND returned something."""
common = []
for order in orders:
for ret in returns:
if order["customer_id"] == ret["customer_id"]:
if order["customer_id"] not in common:
common.append(order["customer_id"])
return common
# Correct, but O(n*m) time and O(n) membership check — O(n²m) overall
# STEP 2: Profile it — find the bottleneck is the nested loop
# (results would show find_common_customers consuming 95% of time)
# STEP 3: Fix the algorithm — O(n+m) with sets
def find_common_customers_fast(orders: list[dict], returns: list[dict]) -> list[str]:
"""Same result, O(n+m) via set intersection."""
order_customers = {o["customer_id"] for o in orders}
return_customers = {r["customer_id"] for r in returns}
return sorted(order_customers & return_customers) # O(min(n,m))
# STEP 4: Measure the improvement
import timeit
orders = [{"customer_id": i % 10000} for i in range(50_000)]
returns = [{"customer_id": i % 8000} for i in range(20_000)]
slow = timeit.timeit(lambda: find_common_customers(orders, returns), number=1)
fast = timeit.timeit(lambda: find_common_customers_fast(orders, returns), number=10)
print(f"Slow: {slow:.3f}s Fast: {fast/10:.4f}s Speedup: {slow/(fast/10):.0f}x")
Slow: 12.841s Fast: 0.004s Speedup: 3210x
Consulting lens: the measure-first mindset
In a performance engagement, the first deliverable is always a profiling report — not a list of "optimizations to try." Without data, you're guessing. With cProfile output showing 90% of time in a specific function, you have a focused, defensible target. This discipline also prevents wasted effort: it's common to profile code and find that the "obvious" bottleneck — the code the developer worried about — is actually fast, while a completely overlooked utility function consumes 80% of the time. Measure, then fix, then measure again. The before/after numbers are your proof of value.
WHY Why is Python "slow" yet so widely used? Python trades execution speed for development speed. The interpreter overhead matters for tight numeric loops — and that's exactly what NumPy/pandas/TensorFlow push into C/C++. For I/O-bound services (which most web apps are), Python's speed is rarely the bottleneck — the database or network is. The 10× developer productivity gain over C is worth the 50× runtime cost when the bottleneck is elsewhere. Python is slow in the wrong places only when you use it wrong.
HOW How does Numba's JIT work? Numba compiles Python functions decorated with @numba.jit to native machine code on first call using LLVM. The compiled function bypasses the Python interpreter entirely for subsequent calls. It works best on numerical loops over NumPy arrays — exactly where NumPy's vectorization can't easily express complex per-element logic. For those cases, Numba can achieve C-like speeds with Python syntax.
WHERE Where does optimization effort have the highest ROI? Hot paths called millions of times: inner loops of data processing, request handlers under high load, recursive computations on large inputs. The Pareto principle applies: 80% of execution time is in 20% of the code. Profile, find that 20%, and focus all optimization effort there. Optimizing cold paths — setup code, error handlers, one-time initialization — yields almost no measurable improvement.
Knowledge check
InterviewA function is "too slow." What is the correct first step before changing any code?
- Rewrite the inner loop as a one-liner comprehension.
- Profile it with
cProfileortimeitto find where time is actually spent — the bottleneck is often not where you expect. - Add more threads and hope it helps.
- Rewrite the whole program in C first.
Answer
Profile it with cProfile or timeit to find where time is actually spent — the bottleneck is often not where you expect.
"Measure, don't guess" is the foundational optimization principle. Profiling reveals where time is truly spent — frequently a surprising spot. You fix the real bottleneck instead of optimizing irrelevant code. The biggest gains usually come from a better algorithm or data structure once the hotspot is identified, not from premature micro-tuning. The before/after measurement also proves the optimization worked.
Knowledge check
Concept CheckWhy is a NumPy vectorized operation typically 50-100× faster than an equivalent Python loop over the same data?
- NumPy uses multiple CPU cores automatically for all operations.
- NumPy runs the loop in precompiled C over contiguous typed memory, eliminating per-element Python interpreter overhead (bytecode dispatch, type checking, reference counting).
- NumPy uses a GPU for all array operations by default.
- There is no real difference; NumPy just looks shorter.
Answer
NumPy runs the loop in precompiled C over contiguous typed memory, eliminating per-element Python interpreter overhead (bytecode dispatch, type checking, reference counting).
A Python loop pays interpreter overhead on every iteration: fetch bytecode, dispatch to handler, unbox the Python object, do the arithmetic, re-box the result, manage reference counts — all for each element. NumPy stores data as contiguous, fixed-type memory (no boxing) and calls a precompiled C function that runs the same loop without any Python overhead. For 1M float additions, that's 1M interpreter rounds vs 1 C function call over raw memory.
Knowledge check
DesignWhen is @functools.lru_cache NOT appropriate to use?
- When the function is called more than once with the same arguments.
- When the function has side effects, returns results that change over time, or takes mutable arguments (lists, dicts) — the cache would return stale or incorrect results.
- When the function is defined inside a class.
- When the function is recursive — caches don't work with recursion.
Answer
When the function has side effects, returns results that change over time, or takes mutable arguments (lists, dicts) — the cache would return stale or incorrect results.
lru_cache is only correct for pure functions: same inputs always produce same outputs with no side effects. It's inappropriate when: (1) the function sends emails, writes to a database, or has other side effects — the cache prevents repeat executions; (2) the result depends on mutable external state (current time, database values) — the cached result becomes stale; (3) arguments include mutable types like lists or dicts — they're not hashable and lru_cache raises TypeError.
Exercise: Optimize a Slow ETL Function
The following function is reported to be slow on large datasets. Profile it to find the bottleneck, then rewrite it using the correct data structures and Python idioms. Aim for at least 100× improvement on n=100,000 records. Explain what the bottleneck was and why your fix works.
def slow_etl(records, valid_ids, category_map):
results = []
for record in records:
if record["id"] in valid_ids: # valid_ids is a list
category = ""
for k, v in category_map: # category_map is a list of (code, name)
if k == record["category_code"]:
category = v
break
name = record.get("name", "")
name = name.strip().title()
results.append({
"id": record["id"],
"name": name,
"category": category,
})
return results
Show solution
"""
BOTTLENECKS IDENTIFIED:
1. `record["id"] in valid_ids` — valid_ids is a list → O(n) per record → O(n²) overall
2. Linear scan over category_map for every record → O(m) per record → O(n×m) overall
3. Both are redundant lookups on fixed data — convert to O(1) structures ONCE
FIX:
1. Convert valid_ids list to a set → O(1) membership
2. Convert category_map list of tuples to a dict → O(1) code lookup
3. Both conversions are O(n) one-time cost, done before the main loop
SPEEDUP: O(n²) → O(n) = ~1000× for n=100k, ~10000× for n=1M
"""
def fast_etl(records: list[dict],
valid_ids: list,
category_map: list[tuple]) -> list[dict]:
# Convert inputs ONCE, before the loop
valid_id_set = set(valid_ids) # O(n) → O(1) lookup
category_lookup = dict(category_map) # O(m) → O(1) lookup
results = []
for record in records: # O(n) — one pass
if record["id"] not in valid_id_set: # O(1)
continue
results.append({
"id": record["id"],
"name": record.get("name", "").strip().title(),
"category": category_lookup.get(record["category_code"], ""), # O(1)
})
return results
# Benchmark
import timeit, random
n = 100_000
records = [
{"id": i, "name": f" item {i} ", "category_code": f"C{i % 50}"}
for i in range(n)
]
valid_ids = list(range(0, n, 2)) # every other id
category_map = [(f"C{i}", f"Category {i}") for i in range(50)]
slow_time = timeit.timeit(
lambda: slow_etl(records, valid_ids, category_map), number=1)
fast_time = timeit.timeit(
lambda: fast_etl(records, valid_ids, category_map), number=10)
print(f"Slow: {slow_time:.2f}s Fast: {fast_time/10:.4f}s "
f"Speedup: {slow_time/(fast_time/10):.0f}x")
# Slow: 45.2s Fast: 0.011s Speedup: 4109x
Key takeaways
- Profile before optimizing —
cProfilefinds the hotspot;timeitmeasures small snippets precisely. - The optimization ladder: algorithm > caching > vectorization > compiled > micro-optimize. Almost never get to the bottom.
@functools.cache(unbounded) and@lru_cache(maxsize=N)memoize pure function results.- NumPy operations are 50-100× faster than Python loops — push numeric computation into C-level arrays.
- The single highest-leverage optimization is almost always replacing a data structure: O(n) list membership → O(1) set membership.