languages/python/performance.md

Python Performance: Profiling, Compilation, and Caching (2026)

A practical guide to Python performance work: profiling with py-spy, Scalene, and cProfile; common bottlenecks; compilation options (mypyc, Cython, Nuitka, PyPy); and caching strategies from functools.cache to external caches.

Introduction / Overview

Python performance work in 2026 follows a strict discipline: measure before optimizing. The tooling for measurement has matured significantly - py-spy for zero-instrumentation production sampling, Scalene for line-level CPU/GPU/memory attribution with AI-assisted suggestions, and cProfile for deterministic, stdlib-native profiling. Once a genuine bottleneck is identified, the response ranges from algorithmic fixes, to targeted compilation (mypyc, Cython, Nuitka), to full alternative-interpreter adoption (PyPy), to caching.

This document assumes the reader has already applied the language-level best practices in Python General Best Practices and the concurrency model guidance in Python Concurrency; performance work here is the next layer after correct, idiomatic code.

When to use this guidance: A service or script is measurably too slow (SLA violation, unacceptable batch job runtime, high infrastructure cost), and you need a systematic approach rather than guesswork.

Core Concepts

  • Profile before optimizing, always: Intuition about "the slow part" is wrong more often than developers expect; a profiler-driven decision prevents wasted effort optimizing code that contributes negligibly to total runtime.
  • Sampling vs deterministic (tracing) profilers: Sampling profilers (py-spy, Scalene) periodically snapshot the call stack with very low overhead, safe for production. Deterministic profilers (cProfile) instrument every function call, giving exact counts at higher overhead, better suited to local development.
  • CPU time vs wall-clock time vs memory: A bottleneck may be CPU-bound (computation), I/O-bound (waiting on network/disk, invisible to a CPU profiler), or memory-bound (allocation/GC pressure); the right profiler and interpretation differs for each.
  • Compilation trades flexibility for speed: mypyc, Cython, and Nuitka all convert Python (or a typed subset) to C, trading dynamic flexibility and pure-Python debuggability for execution speed on the compiled portion.
  • Caching trades memory/staleness for CPU/latency: Every cache introduces an invalidation problem; the right caching layer depends on data volatility, sharing across processes, and acceptable staleness.

Best Practices

1. Start with py-spy for Zero-Overhead Production Diagnosis

Rationale: py-spy attaches to a running process from outside (no code changes, no restart) and samples the call stack, making it safe to run against live production traffic to find real-world hot paths.

py-spy top --pid 12345
py-spy record -o profile.svg --pid 12345 --duration 60

Why this works well in production: The flamegraph (profile.svg) immediately shows which call stacks consume the most sampled time, without the overhead or restart required by instrumentation-based tools.

Permissions note: On Linux, py-spy attaches via ptrace, which the kernel restricts by default. Do not reach for a blanket sudo py-spy ... for the whole profiling session; instead scope the privilege once - sudo setcap capsysptrace=eip $(which py-spy) grants the single CAPSYSPTRACE capability to the binary permanently, after which py-spy runs unprivileged for every subsequent invocation. In a container, pass --cap-add=SYSPTRACE to the profiling container instead of running it --privileged. This is the same least-privilege, capability-scoped pattern documented in Autonomous, Least-Privilege, Portable Execution Environments.

2. Use Scalene for Line-Level Attribution in Development

Rationale: Scalene separates time spent in Python versus native/C code and attributes both CPU and memory usage down to individual lines, which cProfile cannot do for memory and struggles to do precisely for native-code time.

scalene --html --outfile profile.html my_script.py

Production tip: Use Scalene locally or in a staging environment against a representative workload; its instrumentation overhead, while low, is higher than py-spy's pure sampling and is better suited to development-time investigation than always-on production monitoring.

3. Use cProfile for Precise, Reproducible Function-Level Counts

Rationale: When you need exact call counts and cumulative time per function (not just samples), cProfile is the stdlib-native, dependency-free option, ideal for CI performance regression tests on hot functions.

import cProfile
import pstats

profiler = cProfile.Profile()
profiler.enable()
run_batch_job()
profiler.disable()

stats = pstats.Stats(profiler).sort_stats("cumulative")
stats.print_stats(20)
python -m cProfile -o out.prof my_script.py

Why this works well in production: cProfile output is easy to snapshot and diff between commits, making it suitable for automated "this function must not regress past X ms" CI checks.

4. Reach for functools.cache/lru_cache Before Building a Custom Cache

Rationale: For pure, deterministic functions with a bounded, hashable input space, the standard library memoization decorators eliminate repeated computation with zero infrastructure.

from functools import lru_cache

@lru_cache(maxsize=2048)
def compute_shipping_zone(postal_code: str) -> str:
    return expensive_zone_lookup(postal_code)

Caveats: Only safe for pure functions (no side effects, output depends only on input); unbounded maxsize=None risks unbounded memory growth for high-cardinality inputs - always set an explicit bound in long-running services.

5. Use an External Cache (Redis/Memcached) for Cross-Process or Cross-Request State

Rationale: functools.cache is per-process, in-memory, and lost on restart. For data shared across multiple worker processes or across requests in a horizontally scaled service, an external cache is required.

import redis.asyncio as redis
import json

cache = redis.Redis(host="cache.internal", decode_responses=True)

async def get_user_profile(user_id: int) -> dict:
    cached = await cache.get(f"user_profile:{user_id}")
    if cached is not None:
        return json.loads(cached)
    profile = await fetch_user_profile_from_db(user_id)
    await cache.set(f"user_profile:{user_id}", json.dumps(profile), ex=300)
    return profile

Production tip: Always set a TTL (ex=...); a cache without expiration is a slow-motion memory leak and a source of stale-data bugs after upstream data changes.

6. Compile Genuine Hot Paths with mypyc When Already Fully Typed

Rationale: mypyc compiles type-annotated Python modules to C extensions with no syntax changes required, making it the lowest-friction compilation option for codebases that already maintain strict type annotations (mypy itself has used this to achieve a roughly 4x speedup).

uv add --group build mypy
mypyc src/myproject/hot_path.py

When this is the right tool: The hot module is CPU-bound, already fully type-annotated, and does not rely heavily on dynamic features (getattr on arbitrary names, monkey-patching) that mypyc cannot compile efficiently.

Common Pitfalls & Anti-Patterns

Pitfall: Optimizing Without Profiling First

Problem: Developers frequently optimize the code that looks slow (nested loops, string concatenation) while the actual bottleneck is an N+1 database query or an unindexed lookup invisible without profiling.

Recommended approach: Always start with py-spy record or cProfile against a realistic workload before changing a single line for "performance."

Pitfall: Unbounded lru_cache on High-Cardinality Input

Problem: @lru_cache(maxsize=None) on a function called with millions of distinct arguments (e.g., per-request IDs) grows memory without bound until the process is killed.

Recommended approach: Set an explicit, workload-appropriate maxsize, or use a TTL-based external cache instead when the input space is unbounded.

Pitfall: Premature Compilation of Code That Isn't the Bottleneck

Problem: Reaching for Cython or Nuitka on a module that accounts for 2% of total runtime adds build complexity, debugging difficulty, and CI overhead for negligible benefit.

Recommended approach: Compile only modules identified as genuine hot paths by profiling, and measure the actual speedup after compilation - some workloads (I/O-bound, dict-heavy) see minimal benefit from compilation.

Pitfall: String Concatenation in Tight Loops

Problem: Repeated result += chunk in a loop is quadratic due to string immutability, since each concatenation allocates a new string.

Recommended approach:

# Slow: O(n^2)
result = ""
for chunk in chunks:
    result += chunk

# Fast: O(n)
result = "".join(chunks)

Testing Strategies

  • Add regression tests around performance-critical functions using pytest-benchmark, asserting execution time stays within a tolerance band across commits.
  • Use cProfile snapshots committed as CI artifacts to visually diff hot-path changes over time.
  • Test cache correctness explicitly: assert cache hits return identical results to cache misses, and that TTL expiration behaves as expected (using freezegun/time-machine to control time in tests).
  • For compiled modules (mypyc, Cython), run the same test suite against both the pure-Python and compiled builds in CI to catch behavioral divergence introduced by compilation.

See Python Testing Guide for the broader pytest ecosystem and Python Concurrency for profiling concurrent workloads.

Performance, Security & Scalability Considerations

  • Profiling overhead in production: py-spy's out-of-process sampling has negligible overhead and is safe to run continuously or on-demand against live traffic; instrumentation-based profilers (cProfile, Scalene) should generally be reserved for staging or targeted, time-boxed production investigations.
  • Cache poisoning and staleness as a security/correctness concern: An external cache keyed insufficiently (e.g., missing tenant ID in a multi-tenant system) can leak one customer's cached data to another - always include the full authorization context in cache keys.
  • Memory profiling for cost control: Scalene's memory attribution is directly actionable for reducing cloud memory-tier costs in memory-constrained containers.
  • Compilation and reproducible builds: Compiled artifacts (mypyc/Cython .so files, Nuitka binaries) are platform- and Python-version-specific; CI must build them for every target platform, and this build step must itself be tested, not just trusted.

Edge Cases & Advanced Usage

  • PyPy for long-running, hot-loop-heavy pure-Python workloads: PyPy's JIT can substantially outperform CPython on pure-Python numeric loops with short startup-to-runtime ratios being the main caveat - poor fit for short-lived CLI invocations, strong fit for long-running services with sustained CPU-bound Python loops and full C-extension compatibility.
  • Nuitka for whole-program compilation: Nuitka compiles an entire application (not just a module) to C++, useful for distributing a self-contained binary or squeezing out compounding gains across a whole numerical pipeline; compile time and binary size are the trade-offs.
  • Cython for fine-grained control: When mypyc's type-annotation-only model is insufficient (need explicit C types, manual GIL release with nogil, direct C library calls), Cython's .pyx syntax offers finer control at the cost of a distinct dialect to maintain.
  • Cache stampede protection: Under high concurrency, many requests missing a cache simultaneously can all trigger the same expensive recomputation; use a lock or "request coalescing" pattern (e.g., asyncio.Lock per key, or probabilistic early expiration) to prevent a stampede on cache expiry.
  • GPU/vectorized alternatives: For numerically heavy workloads, consider whether NumPy/Polars vectorization eliminates the need for compilation entirely before reaching for mypyc/Cython/Nuitka - vectorized operations frequently outperform even compiled scalar Python loops.

When Not to Use / Alternatives

DimensionmypycCythonNuitkaPyPy
Setup frictionLow (already-typed code)Medium (new .pyx dialect)Low (compiles existing scripts)Very low (drop-in interpreter)
Best fitFully-typed library/moduleNumeric kernels needing C controlWhole-app distribution/binaryLong-running CPU-bound services
C-extension compatibilityFull (same ABI)FullFullImproving, some gaps historically
Typical speedup~2-4xHighly variable, often largerWorkload-dependent, often 2-10x+Large on hot pure-Python loops

Do not compile or add caching before profiling confirms a real bottleneck; the majority of "slow" Python services are I/O-bound (database, network) and gain nothing from compilation, only from query optimization, connection pooling, or an external cache.

Prefer algorithmic fixes first: A better data structure or algorithm (e.g., a set membership check instead of a list scan) frequently outperforms any compilation strategy and requires no new tooling.

Glossary

  • Sampling profiler: A profiler (e.g., py-spy) that periodically snapshots the call stack rather than instrumenting every call, yielding low overhead at the cost of statistical (not exact) results.
  • Deterministic/tracing profiler: A profiler (e.g., cProfile) that instruments every function call for exact counts and timing, at higher runtime overhead.
  • Flamegraph: A visualization of stack samples over time, where width represents relative time spent in a given call stack.
  • functools.lru_cache: A stdlib decorator providing bounded, in-process memoization for pure functions.
  • Cache stampede: A failure mode where many concurrent requests simultaneously miss an expiring cache entry and trigger redundant, expensive recomputation.
  • JIT (Just-In-Time compilation): Runtime compilation of hot code paths to machine code, as used by PyPy's interpreter.