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:

  1. Measure: Profile to find the actual bottlenecks
  2. Analyze: Understand why code is slow
  3. Optimize: Apply targeted improvements
  4. Verify: Measure again to confirm improvement
  5. Repeat: Only if performance is still inadequate

The Golden Rules of Optimization

1️⃣
Make It Work

Write correct, readable code first. Don't optimize prematurely.

2️⃣
Make It Right

Test thoroughly. Bugs are worse than slow code.

3️⃣
Make It Fast

Only if needed. Measure first, optimize the bottlenecks.

Donald Knuth's famous quote: "Premature optimization is the root of all evil." Most code doesn't need optimization. Profile first to find the 5-10% that does.

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
When to use each:
  • time.perf_counter() - Quick timing for larger code blocks
  • timeit - Accurate microbenchmarks for small snippets
  • timeit.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 functions
Expected 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
Reading profiler output:
  • 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
Focus on functions with high cumtime and many ncalls.

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 False
Caching 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
What the numbers say: the set comprehension is the only one of the three worth calling faster, and it wins because it skips a repeated .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)
Memory optimization tips:
  • 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.

ComplexityNameExample10 items1000 items
O(1)ConstantDict lookup, array access1 op1 op
O(log n)LogarithmicBinary search3 ops10 ops
O(n)LinearLoop through list10 ops1,000 ops
O(n log n)LinearithmicEfficient sorting30 ops10,000 ops
O(n²)QuadraticNested loops100 ops1,000,000 ops
O(2ⁿ)ExponentialRecursive fibonacci1,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

Do This
  • 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
Avoid This
  • 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)
What the measurements say:
  • 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
The speed work bought 1.3x. The memory work bought 9x, and it is the one that decides whether the job survives a file that no longer fits in RAM.

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