Performance & Profiling
Write fast code by measuring first and optimizing smart.
Why Performance Matters
Performance issues rarely stem from "slow hardware." They typically originate from inefficient algorithms, unnecessary work, or hidden bottlenecks. Professional developers measure performance scientifically, identify hot spots with data, and optimize only where it provides real value.
The Performance Optimization Process:
- Measure: Profile to find the actual bottlenecks
- Analyze: Understand why code is slow
- Optimize: Apply targeted improvements
- Verify: Measure again to confirm improvement
- Repeat: Only if performance is still inadequate
The Golden Rules of Optimization
Make It Work
Write correct, readable code first. Don't optimize prematurely.
Make It Right
Test thoroughly. Bugs are worse than slow code.
Make It Fast
Only if needed. Measure first, optimize the bottlenecks.
1. Measuring Performance: Time & Timeit
Before optimizing, you need baseline measurements. Python provides several tools for timing code execution.
Every timing on this page comes from a real run on one machine, so your absolute numbers will differ. The ratios between the versions are the part that carries over.
Using time.perf_counter()
import time
def slow_function():
total = 0
for i in range(1_000_000):
total += i
return total
# Basic timing
start = time.perf_counter()
result = slow_function()
end = time.perf_counter()
print(f"Elapsed time: {end - start:.4f} seconds")
# Context manager for cleaner timing
from contextlib import contextmanager
@contextmanager
def timer(name="Operation"):
start = time.perf_counter()
yield
end = time.perf_counter()
print(f"{name} took {end - start:.4f}s")
# Usage
with timer("Slow function"):
result = slow_function()Expected Output:
Elapsed time: 0.0165 seconds Slow function took 0.0170s
Using timeit Module
import timeit
# Compare different approaches
# Approach 1: List concatenation
time1 = timeit.timeit(
stmt="result = []\nfor i in range(1000): result = result + [i]",
number=1000
)
# Approach 2: List append
time2 = timeit.timeit(
stmt="result = []\nfor i in range(1000): result.append(i)",
number=1000
)
# Approach 3: List comprehension
time3 = timeit.timeit(
stmt="result = [i for i in range(1000)]",
number=1000
)
print(f"Concatenation: {time1:.4f}s") # Slowest
print(f"Append: {time2:.4f}s") # Medium
print(f"Comprehension: {time3:.4f}s") # Fastest
# Using repeat for more accurate results
times = timeit.repeat(
stmt="sum(range(1000))",
repeat=5,
number=10000
)
print(f"Best of 5: {min(times):.6f}s")
print(f"Average: {sum(times)/len(times):.6f}s")Expected Output:
Concatenation: 0.4978s Append: 0.0103s Comprehension: 0.0082s Best of 5: 0.054984s Average: 0.056283s
time.perf_counter()- Quick timing for larger code blockstimeit- Accurate microbenchmarks for small snippetstimeit.repeat()- Multiple runs to reduce variance
2. Profiling: Finding the Bottlenecks
Profilers show exactly where your program spends its time, revealing the functions that are actual bottlenecks rather than the ones you think are slow.
cProfile: Built-in Profiler
# Save as profiler.py, then run: python profiler.py
import cProfile
import pstats
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n-1) + fibonacci(n-2)
def calculate_all():
results = []
for i in range(30):
results.append(fibonacci(i))
return results
# Method 1: Quick profiling
cProfile.run('calculate_all()')
# Method 2: Save and analyze results
profiler = cProfile.Profile()
profiler.enable()
calculate_all()
profiler.disable()
# Print stats
stats = pstats.Stats(profiler)
stats.strip_dirs()
stats.sort_stats('cumulative')
stats.print_stats(10) # Top 10 functionsExpected Output:
4356620 function calls (64 primitive calls) in 0.473 seconds
Ordered by: standard name
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.000 0.000 0.473 0.473 <string>:1(<module>)
1 0.000 0.000 0.473 0.473 profiler.py:10(calculate_all)
4356586/30 0.473 0.000 0.473 0.016 profiler.py:5(fibonacci)
1 0.000 0.000 0.473 0.473 {built-in method builtins.exec}
30 0.000 0.000 0.000 0.000 {method 'append' of 'list' objects}
1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}
4356618 function calls (62 primitive calls) in 0.467 seconds
Ordered by: cumulative time
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.000 0.000 0.467 0.467 profiler.py:10(calculate_all)
4356586/30 0.467 0.000 0.467 0.016 profiler.py:5(fibonacci)
1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}
30 0.000 0.000 0.000 0.000 {method 'append' of 'list' objects}Line Profiler: Line-by-Line Analysis
# Install: pip install line_profiler
# Run with: kernprof -l -v profile_demo.py
def complex_calculation(value):
return sum(i * value for i in range(500))
# kernprof injects @profile into builtins, so no import is needed
@profile
def process_data(items):
result = []
for item in items:
# Expensive operation
processed = complex_calculation(item)
result.append(processed)
return result
process_data(range(1000))Expected Output:
Wrote profile results to 'profile_demo.py.lprof'
Timer unit: 1e-06 s
Total time: 0.0901326 s
File: profile_demo.py
Function: process_data at line 8
Line # Hits Time Per Hit % Time Line Contents
==============================================================
8 @profile
9 def process_data(items):
10 1 0.4 0.4 0.0 result = []
11 1001 154.0 0.2 0.2 for item in items:
12 # Expensive operation
13 1000 89813.2 89.8 99.6 processed = complex_calculation(item)
14 1000 164.6 0.2 0.2 result.append(processed)
15 1 0.4 0.4 0.0 return result- ncalls: Number of times function was called
- tottime: Total time spent in this function (excluding subcalls)
- cumtime: Cumulative time (including subcalls)
- percall: Average time per call
3. Common Performance Bottlenecks
Understanding common performance killers helps you write better code from the start.
4. Proven Optimization Techniques
Use Built-in Functions and Data Structures
# Built-ins are implemented in C and highly optimized
# Bad: Manual implementation
def sum_slow(numbers):
total = 0
for n in numbers:
total += n
return total
# Good: Built-in
def sum_fast(numbers):
return sum(numbers)
# Bad: List for membership testing
def has_duplicate_slow(items):
seen = []
for item in items:
if item in seen: # O(n) lookup
return True
seen.append(item)
return False
# Good: Set for membership testing
def has_duplicate_fast(items):
seen = set()
for item in items:
if item in seen: # O(1) lookup
return True
seen.add(item)
return FalseCaching with functools.lru_cache
import time
from functools import lru_cache
# Without caching - exponential time complexity
def fibonacci_slow(n):
if n <= 1:
return n
return fibonacci_slow(n-1) + fibonacci_slow(n-2)
# With caching - linear time complexity
@lru_cache(maxsize=None)
def fibonacci_fast(n):
if n <= 1:
return n
return fibonacci_fast(n-1) + fibonacci_fast(n-2)
start = time.perf_counter()
fibonacci_slow(35)
without_cache = time.perf_counter() - start
start = time.perf_counter()
fibonacci_fast(35)
with_cache = time.perf_counter() - start
print(f"Without cache: {without_cache:.3f}s")
print(f"With cache: {with_cache:.6f}s")
print(f"Speedup: {without_cache / with_cache:,.0f}x")
# Check cache stats
print(fibonacci_fast.cache_info())
# Clear the cache if the underlying data changes
fibonacci_fast.cache_clear()
print(fibonacci_fast.cache_info())Expected Output:
Without cache: 0.530s With cache: 0.000015s Speedup: 34,199x CacheInfo(hits=33, misses=36, maxsize=None, currsize=36) CacheInfo(hits=0, misses=0, maxsize=None, currsize=0)
The same decorator pays off anywhere a repeated call is expensive and its answer does not change, such as a lookup that would otherwise hit the network every time. Cache only pure functions: the second call returns the first call's result, so a function whose answer depends on time or on data that changes underneath it will keep handing back a stale value until you call cache_clear().
import requests
from functools import lru_cache
# Real-world example: API calls
@lru_cache(maxsize=128)
def fetch_user_data(user_id):
# Expensive API call
response = requests.get(f"https://api.example.com/users/{user_id}")
return response.json()
# First call: makes API request
user = fetch_user_data(123)
# Second call: returns cached result instantly
user = fetch_user_data(123) # No API call!Generators for Memory Efficiency
import sys
def process_line(line):
return line.strip().upper()
# Bad: Load everything into memory
def process_large_file_slow(filename):
with open(filename) as f:
lines = f.readlines() # Loads entire file!
results = []
for line in lines:
results.append(process_line(line))
return results
# Good: Process one line at a time
def process_large_file_fast(filename):
def line_generator():
with open(filename) as f:
for line in f: # Lazy iteration
yield process_line(line)
return line_generator()
# A small stand-in for the huge file
with open('huge_file.txt', 'w') as f:
f.writelines(f"record {i}\n" for i in range(1, 4))
# Usage
for result in process_large_file_fast('huge_file.txt'):
print(result) # Memory stays constant!
# Generator expressions vs list comprehensions
# Bad: Creates entire list in memory
squares_list = [x**2 for x in range(1_000_000)]
# Good: Creates values on demand
squares_gen = (x**2 for x in range(1_000_000))
print(f"List: {sys.getsizeof(squares_list):>9,} bytes")
print(f"Generator: {sys.getsizeof(squares_gen):>9,} bytes")
# Use case: Sum of squares
total = sum(x**2 for x in range(1_000_000)) # Memory efficient!
print(f"Sum of squares: {total:,}")Expected Output:
RECORD 1 RECORD 2 RECORD 3 List: 8,448,728 bytes Generator: 200 bytes Sum of squares: 333,332,833,333,500,000
List Comprehensions vs Loops
import timeit
words = ["alpha", "beta", "gamma", "delta", "epsilon"] * 200
# Slow: explicit loop
def squares_loop():
result = []
for x in range(1000):
if x % 2 == 0:
result.append(x ** 2)
return result
# Fast: list comprehension
def squares_comprehension():
return [x ** 2 for x in range(1000) if x % 2 == 0]
# Slow: explicit loop
def lengths_loop():
word_lengths = {}
for word in words:
word_lengths[word] = len(word)
return word_lengths
# Fast: dict comprehension
def lengths_comprehension():
return {word: len(word) for word in words}
# Slow: explicit loop
def unique_loop():
unique_lengths = set()
for word in words:
unique_lengths.add(len(word))
return unique_lengths
# Fast: set comprehension
def unique_comprehension():
return {len(word) for word in words}
for loop_fn, comp_fn in [(squares_loop, squares_comprehension),
(lengths_loop, lengths_comprehension),
(unique_loop, unique_comprehension)]:
assert loop_fn() == comp_fn() # Same result, different syntax
loop_time = min(timeit.repeat(loop_fn, repeat=5, number=2000)) / 2000
comp_time = min(timeit.repeat(comp_fn, repeat=5, number=2000)) / 2000
name = loop_fn.__name__.removesuffix("_loop")
print(f"{name:8s} loop {loop_time * 1e6:5.1f} us "
f"comprehension {comp_time * 1e6:5.1f} us "
f"{loop_time / comp_time:.2f}x")Expected Output:
squares loop 22.3 us comprehension 21.1 us 1.06x lengths loop 14.8 us comprehension 14.6 us 1.01x unique loop 14.6 us comprehension 11.2 us 1.30x
.add lookup and call. The dict version is a wash. Comprehensions are still the right default, just for readability rather than for speed: reach for them because they say what they build in one line, and expect a few percent, not a few times.5. Memory Profiling
Sometimes performance issues stem from excessive memory usage, causing swapping or out-of-memory errors.
tracemalloc (Built-in)
# Save as memory_demo.py, then run: python memory_demo.py
import os
import tracemalloc
# Start tracking
tracemalloc.start()
# Your code here
data = [i ** 2 for i in range(1_000_000)]
# Get current memory usage
current, peak = tracemalloc.get_traced_memory()
print(f"Current memory: {current / 1024 / 1024:.2f} MB")
print(f"Peak memory: {peak / 1024 / 1024:.2f} MB")
# Get top memory allocations
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')
print("Top 3 allocation sites:")
for stat in top_stats[:3]:
frame = stat.traceback[0]
print(f" {os.path.basename(frame.filename)}:{frame.lineno} {stat.size:,} bytes")
tracemalloc.stop()Expected Output:
Current memory: 38.57 MB Peak memory: 38.57 MB Top 3 allocation sites: memory_demo.py:9 40,448,128 bytes memory_demo.py:14 126 bytes memory_demo.py:13 65 bytes
memory_profiler (External)
# Install: pip install memory_profiler
# Run with: python -m memory_profiler mem_demo.py
from memory_profiler import profile
@profile
def memory_heavy_function():
# Creates large list
a = [1] * (10 ** 6)
# Creates another large list
b = [2] * (2 * 10 ** 7)
# Delete first list
del a
return sum(b)
memory_heavy_function()Expected Output:
Filename: mem_demo.py
Line # Mem usage Increment Occurrences Line Contents
=============================================================
6 22.3 MiB 22.3 MiB 1 @profile
7 def memory_heavy_function():
8 # Creates large list
9 29.9 MiB 7.6 MiB 1 a = [1] * (10 ** 6)
10
11 # Creates another large list
12 182.5 MiB 152.6 MiB 1 b = [2] * (2 * 10 ** 7)
13
14 # Delete first list
15 174.9 MiB -7.6 MiB 1 del a
16
17 174.9 MiB 0.0 MiB 1 return sum(b)- Use generators instead of lists when possible
- Process data in chunks for large files
- Delete large objects when no longer needed
- Use
__slots__for classes with many instances - Consider numpy arrays for numerical data (more memory efficient)
Understanding Time Complexity (Big O)
Understanding algorithmic complexity helps you predict performance as data grows.
| Complexity | Name | Example | 10 items | 1000 items |
|---|---|---|---|---|
| O(1) | Constant | Dict lookup, array access | 1 op | 1 op |
| O(log n) | Logarithmic | Binary search | 3 ops | 10 ops |
| O(n) | Linear | Loop through list | 10 ops | 1,000 ops |
| O(n log n) | Linearithmic | Efficient sorting | 30 ops | 10,000 ops |
| O(n²) | Quadratic | Nested loops | 100 ops | 1,000,000 ops |
| O(2ⁿ) | Exponential | Recursive fibonacci | 1,024 ops | 💥 Unusable |
# Common Python operations and their complexity
# O(1) - Constant
item = my_dict[key] # Dict/set lookup
item = my_list[5] # List index access
my_list.append(item) # List append
my_set.add(item) # Set add
# O(log n) - Logarithmic
bisect.bisect_left(sorted_list, x) # Binary search
# O(n) - Linear
sum(my_list) # Iterate through list
max(my_list) # Find maximum
item in my_list # List membership (use set instead!)
my_list.count(item) # Count occurrences
# O(n log n) - Linearithmic
sorted(my_list) # Efficient sort
my_list.sort() # In-place sort
# O(n²) - Quadratic (avoid!)
for i in range(n):
for j in range(n):
compare(items[i], items[j]) # nested loop operations
# Choosing the right data structure matters!
# List: O(n) for 'in', O(1) for append
# Set: O(1) for 'in', O(1) for add
# Dict: O(1) for lookup, O(1) for insert
# Bad: O(n) membership test in loop = O(n²)
def filter_items_slow(items, allowed):
return [item for item in items if item in allowed] # allowed is list
# Good: O(1) membership test in loop = O(n)
def filter_items_fast(items, allowed):
allowed_set = set(allowed)
return [item for item in items if item in allowed_set]Performance Best Practices
- Profile before optimizing
- Use appropriate data structures
- Cache expensive computations
- Use built-in functions
- Process data in batches
- Use generators for large datasets
- Avoid premature optimization
- Measure after optimizing
- Optimizing without measuring
- Using lists for membership tests
- String concatenation in loops
- Repeated expensive operations
- Loading entire files into memory
- Nested loops when unnecessary
- Sacrificing readability for tiny gains
- Ignoring algorithmic complexity
Real-World Optimization Example
Let's see a complete example of identifying and fixing a performance bottleneck.
# Problem: count 100,000 activity records and report the 10 most active users
import random
import time
from collections import Counter, defaultdict
random.seed(42)
logs = [{"user_id": f"u{random.randint(1, 5000)}"} for _ in range(100_000)]
# SLOW VERSION: hand-rolled counting and a hand-rolled sort
def find_top_users_slow(logs):
user_activity = {}
# Count activities per user
for log in logs:
user_id = log['user_id']
if user_id in user_activity:
user_activity[user_id] += 1
else:
user_activity[user_id] = 1
# Sort to find top 10
pairs = []
for user_id, count in user_activity.items():
pairs.append((user_id, count))
pairs.sort(key=lambda pair: pair[1], reverse=True)
return pairs[:10]
# VERSION 1: defaultdict drops the branch, sorted() replaces the manual pass
def find_top_users_faster(logs):
user_activity = defaultdict(int)
for log in logs:
user_activity[log['user_id']] += 1
return sorted(user_activity.items(),
key=lambda pair: pair[1],
reverse=True)[:10]
# VERSION 2: Counter counts in C, most_common uses a heap for small k
def find_top_users_fastest(logs):
user_activity = Counter(log['user_id'] for log in logs)
return user_activity.most_common(10)
def benchmark(fn, repeat=10):
timings = []
for _ in range(repeat):
start = time.perf_counter()
fn(logs)
timings.append(time.perf_counter() - start)
return min(timings)
# All three must agree before any of them can be called an optimization
assert find_top_users_slow(logs) == find_top_users_faster(logs) == find_top_users_fastest(logs)
timings = [(fn.__name__, benchmark(fn))
for fn in (find_top_users_slow, find_top_users_faster, find_top_users_fastest)]
baseline = timings[0][1]
for name, elapsed in timings:
print(f"{name:24s} {elapsed * 1000:6.1f} ms {baseline / elapsed:.2f}x")
print(f"Top 3: {find_top_users_fastest(logs)[:3]}")Expected Output:
find_top_users_slow 7.0 ms 1.00x
find_top_users_faster 6.2 ms 1.12x
find_top_users_fastest 5.3 ms 1.32x
Top 3: [('u3890', 38), ('u4702', 37), ('u3153', 36)]Both rewrites help, and neither is dramatic: swapping in defaultdict and sorted() buys about 1.1x, and Counter with most_common() about 1.3x. That is what measuring is for. The counting loop was never the expensive part, so no amount of tuning it was going to produce the order-of-magnitude win the naming suggests.
Memory is where this problem has real room to move. If the records arrive as a file rather than a list, counting a batch at a time keeps the peak flat instead of holding every record at once:
# VERSION 3: stream the file instead of loading it, when the logs live on disk
import json
import random
import tracemalloc
from collections import Counter
random.seed(42)
with open('activity.jsonl', 'w') as f:
for _ in range(100_000):
f.write(json.dumps({"user_id": f"u{random.randint(1, 5000)}"}) + "\n")
# Loads every record before counting any of them
def top_users_in_memory(log_file):
with open(log_file) as f:
logs = [json.loads(line) for line in f]
return Counter(log['user_id'] for log in logs).most_common(10)
# Counts a batch at a time, so only batch_size records are ever live
def top_users_streaming(log_file, batch_size=10_000):
user_activity = Counter()
batch = []
with open(log_file) as f:
for line in f:
batch.append(json.loads(line))
if len(batch) >= batch_size:
user_activity.update(log['user_id'] for log in batch)
batch = []
# Process remaining
if batch:
user_activity.update(log['user_id'] for log in batch)
return user_activity.most_common(10)
for fn in (top_users_in_memory, top_users_streaming):
tracemalloc.start()
result = fn('activity.jsonl')
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"{fn.__name__:22s} peak {peak / 1024 / 1024:6.2f} MiB top user {result[0]}")Expected Output:
top_users_in_memory peak 27.41 MiB top user ('u3890', 38)
top_users_streaming peak 3.06 MiB top user ('u3890', 38)- Version 1 (defaultdict + sorted): 1.12x faster
- Version 2 (Counter + most_common): 1.32x faster
- Version 3 (batched): same answer, 27.41 MiB peak down to 3.06 MiB, an 89% cut
Key Takeaways
- Always measure first - profiling reveals the real bottlenecks
- Focus optimization efforts - 90% of time is spent in 10% of code
- Choose the right algorithm - O(n) vs O(n²) matters more than micro-optimizations
- Use appropriate data structures - sets for membership, dicts for lookups
- Leverage built-in functions - they're optimized in C
- Cache expensive operations - don't recompute what you already know
- Consider memory efficiency - use generators for large datasets
- Readable code first - optimize only when necessary
Practice Exercises
Exercise 1: Profile and Optimize
Write a function that finds all pairs of numbers in a list that sum to a target. First implement a naive O(n²) solution, profile it, then optimize to O(n) using a set. Compare the performance with timeit for lists of 100, 1000, and 10000 elements.
Exercise 2: Memory Optimization
Create two versions of a function that processes a large CSV file (1M+ rows): one that loads everything into memory, and one that uses generators. Use tracemalloc to compare memory usage.
Exercise 3: Caching Benefits
Implement a function that calculates expensive statistics on data (mean, median, mode). Add @lru_cache and measure the speedup when the function is called multiple times with the same inputs. Check cache_info() to see hit rates.
Additional Resources
- Books: "High Performance Python" by Gorelick & Ozsvald
- Tools: py-spy (sampling profiler), scalene (CPU+memory profiler)
- Visualization: snakeviz (visualize cProfile output)
- Documentation: docs.python.org/3/library/profile.html
- Time Complexity: wiki.python.org/moin/TimeComplexity
What's Next?
Your code is now fast and efficient! Next, learn how to package and share your Python projects with others.
- Package Structure - Organize your code into installable packages
- setup.py & pyproject.toml - Configure your package for distribution
- Publishing to PyPI - Share your package with the Python community