Python Performance
Profile Python code with cProfile and timeit, vectorize with NumPy, cache with lru_cache, and identify real bottlenecks.
Measure First: timeit
Never optimize without measuring. timeit runs code thousands of times to give stable results.
import timeit
# Quick benchmark in code
result = timeit.timeit(
stmt="sum(range(1000))",
number=10_000
)
print(f"{result:.3f}s for 10,000 iterations")
# Compare two approaches
setup = "data = list(range(10_000))"
t1 = timeit.timeit("sum(data)", setup=setup, number=1000)
t2 = timeit.timeit("total = 0\nfor x in data:\n total += x", setup=setup, number=1000)
print(f"sum(): {t1:.4f}s")
print(f"loop: {t2:.4f}s")
In IPython or Jupyter, use the magic:
%timeit sum(range(1_000_000))
# 13.1 ms ± 91.3 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
cProfile: Finding Bottlenecks
import cProfile
import pstats
def slow_function():
total = 0
for i in range(100_000):
total += sum(range(i % 100))
return total
# Profile to file
cProfile.run("slow_function()", "profile_output")
# Analyze results
stats = pstats.Stats("profile_output")
stats.sort_stats("cumulative")
stats.print_stats(10) # top 10 functions by cumulative time
Output looks like:
ncalls tottime percall cumtime percall filename:lineno(function)
100000 0.523 0.000 0.523 0.000 {built-in method builtins.sum}
line_profiler for Line-by-Line
pip install line_profiler
from line_profiler import LineProfiler
def process_items(items):
results = []
for item in items:
cleaned = item.strip().lower() # line 1
words = cleaned.split() # line 2
results.append(" ".join(sorted(words))) # line 3
return results
profiler = LineProfiler()
profiler.add_function(process_items)
profiler.enable()
process_items(["hello world", "foo bar"] * 10_000)
profiler.disable()
profiler.print_stats()
lru_cache: Memoization
Cache expensive function results. The most impactful single-line optimization in Python.
from functools import lru_cache
# Without cache: O(2^n)
def fib(n):
if n < 2:
return n
return fib(n-1) + fib(n-2)
# With cache: O(n)
@lru_cache(maxsize=None)
def fib_cached(n):
if n < 2:
return n
return fib_cached(n-1) + fib_cached(n-2)
import timeit
print(timeit.timeit("fib(30)", globals=globals(), number=1)) # ~0.2s
print(timeit.timeit("fib_cached(30)", globals=globals(), number=1)) # ~0.00001s
# Inspect cache
fib_cached.cache_info() # CacheInfo(hits=28, misses=31, maxsize=None, currsize=31)
fib_cached.cache_clear() # invalidate
cache (Python 3.9+)
from functools import cache # equivalent to lru_cache(maxsize=None)
@cache
def expensive_lookup(key: str) -> dict:
return database.fetch(key)
NumPy Vectorization
Replace Python loops with NumPy operations — C speed with Python syntax.
import numpy as np
import timeit
data = list(range(1_000_000))
arr = np.array(data)
# Python loop
def python_sum_squares(lst):
return sum(x**2 for x in lst)
# NumPy
def numpy_sum_squares(arr):
return np.sum(arr ** 2)
t1 = timeit.timeit(lambda: python_sum_squares(data), number=10)
t2 = timeit.timeit(lambda: numpy_sum_squares(arr), number=10)
print(f"Python: {t1:.3f}s") # ~2.0s
print(f"NumPy: {t2:.3f}s") # ~0.03s — ~60x faster
Avoiding NumPy Anti-Patterns
# Slow — Python loop over NumPy array
total = 0
for x in arr:
total += x # calls Python __add__ on each element
# Fast — vectorized
total = arr.sum()
# Slow — element-wise Python condition
result = np.array([x**2 if x > 0 else 0 for x in arr])
# Fast — NumPy where
result = np.where(arr > 0, arr**2, 0)
String Concatenation
import timeit
words = ["hello"] * 10_000
# Slow — O(n^2) due to immutable string copies
def concat_plus(words):
result = ""
for w in words:
result += w
return result
# Fast — O(n)
def concat_join(words):
return "".join(words)
t1 = timeit.timeit(lambda: concat_plus(words), number=100)
t2 = timeit.timeit(lambda: concat_join(words), number=100)
print(f"Plus: {t1:.3f}s, Join: {t2:.4f}s") # join is ~100x faster
List vs Generator
import sys
# List — all items in memory at once
squares_list = [x**2 for x in range(1_000_000)]
print(sys.getsizeof(squares_list)) # ~8.5 MB
# Generator — lazy, one item at a time
squares_gen = (x**2 for x in range(1_000_000))
print(sys.getsizeof(squares_gen)) # ~120 bytes
# If you only iterate once, use a generator
total = sum(x**2 for x in range(1_000_000))
Dict and Set Lookups
import timeit
data = list(range(100_000))
data_set = set(data)
data_dict = {x: True for x in data}
target = 99_999
# O(n) — scans the whole list
t1 = timeit.timeit(lambda: target in data, number=1000)
# O(1) — hash lookup
t2 = timeit.timeit(lambda: target in data_set, number=1000)
print(f"List: {t1:.4f}s") # ~5s
print(f"Set: {t2:.6f}s") # ~0.0001s
Slots for Object-Heavy Code
import timeit
class SlowPoint:
def __init__(self, x, y, z):
self.x = x; self.y = y; self.z = z
class FastPoint:
__slots__ = ("x", "y", "z")
def __init__(self, x, y, z):
self.x = x; self.y = y; self.z = z
t1 = timeit.timeit(lambda: SlowPoint(1, 2, 3), number=1_000_000)
t2 = timeit.timeit(lambda: FastPoint(1, 2, 3), number=1_000_000)
print(f"Without slots: {t1:.3f}s")
print(f"With slots: {t2:.3f}s") # typically 20-30% faster
Practical Optimization Checklist
- Profile first — find the actual bottleneck
- Replace Python loops with NumPy/Pandas operations
- Add
@lru_cache/@cacheto pure functions called repeatedly - Use
setfor membership testing instead oflist - Use
"".join()instead of string+concatenation - Use generators instead of list comprehensions when you iterate once
- Add
__slots__if creating millions of small objects - Consider
multiprocessingfor CPU-bound work - Consider
asynciofor high-concurrency I/O - Profile again to confirm the improvement
Frequently Asked Questions
How do I know if my code is slow?
Profile first. Don't guess. 90% of execution time is in 10% of code. Use cProfile to find the actual bottleneck before optimizing anything.
Is Python inherently slow?
Pure Python is slower than C or Java for CPU-bound loops. But most real programs are I/O-bound or call into fast C libraries (NumPy, Pandas, SQLAlchemy). Profile before assuming Python is your bottleneck.
When should I use PyPy instead of CPython?
PyPy excels at long-running, CPU-bound pure Python code. It's not a drop-in for projects that rely heavily on C extensions (NumPy, Pandas work better with CPython).