Skip to main content

Why pytest-codspeed?

This guide uses pytest-codspeed because it integrates seamlessly with pytest, the most popular Python testing framework. Your benchmarks live right alongside your tests using the same familiar syntax, no separate infrastructure to maintain. Plus, all of pytest’s ecosystem (parametrization, fixtures, plugins) works seamlessly with your benchmarks. You can even turn existing tests into benchmarks by adding a single decorator.
If you’re wondering whether to use command-line tools like time or hyperfine versus integrated frameworks like pytest-codspeed, check out our Choosing the Right Python Benchmarking Strategy guide for a detailed comparison.

Your First Benchmark

Let’s start by creating a simple benchmark for a recursive Fibonacci function.

Installation

First, add pytest-codspeed to your project’s dependencies using uv:
Don’t have uv?You can use pip install pytest-codspeed instead. uv is a modern, fast Python package manager that we recommend for new projects, but any package manager works fine.

Writing the Benchmark

Create a new file tests/test_benchmarks.py:
tests/test_benchmarks.py
A few things to note: @pytest.mark.benchmark is a standard pytest marker that marks this test as a benchmark. The entire test function is measured, including both the computation and the assertion. It’s a regular pytest test, so you can run it with pytest as usual. The test validates correctness (via assertions) and tracks performance at the same time.

Running the Benchmark

Now run your benchmark:
What does --codspeed do?This flag activates CodSpeed’s benchmarking engine to collect performance measurements. Without it, pytest runs your tests normally without gathering performance data. If you’re not using uv, run pytest tests/ --codspeed instead.
You should see output like this:
terminal
The output shows that test_fib_bench takes about 73 milliseconds to compute fibonacci(30). It ran 40 times in 2.96 seconds to get a reliable measurement.
Understanding the results:
  • Time (best): The fastest single iteration - this is your function’s performance (lower is better).
  • Rel. StdDev: Relative standard deviation - measures consistency between runs (lower means more reliable results).
  • Run time: Total time spent running the benchmark.
  • Iters: How many times your code ran - automatically adjusted based on speed (fast code runs more times for accuracy).

Benchmarking with Arguments

So far, we’ve only tested our function with a single input value (30). But what if we want to see how performance changes with different input sizes? This is where pytest’s @pytest.mark.parametrize comes in, and it works seamlessly with benchmarks. Let’s update our benchmark to test multiple input sizes:
tests/test_benchmarks.py
When you run this benchmark, pytest will create separate test instances for each parameter value, allowing you to compare performance across different inputs:
terminal
Notice how parametrization creates five separate benchmarks, one for each input value. The results reveal the exponential time complexity of our recursive Fibonacci implementation: fibonacci(5) takes virtually no time (0ns) and runs over 1 million iterations, while fibonacci(30) takes 72.9ms and runs only 40 times. This dramatic difference (from nanoseconds to milliseconds) demonstrates how quickly recursive Fibonacci becomes expensive as the input grows.

Multiple Parameters

You can also benchmark across multiple dimensions:
tests/test_benchmarks.py
Then run it:
terminal
This benchmark creates four separate test cases, one for each combination of algorithm and input size. The output clearly shows the dramatic performance difference between the two implementations: the iterative version handles much larger inputs (100, 200) in virtually no time, while the recursive version takes 8.49µs for n=20. Notice how fibonacci_iterative(200) runs over 500,000 iterations in the same time budget that fibonacci(20) only manages about 5,000. Parametrization makes algorithmic trade-offs visible at a glance, helping you choose the most efficient implementation for your use case.

Naming Parametrized Cases

By default, pytest generates benchmark names from the parameter values. That works well for primitives like numbers or short strings, e.g., test_fib_parametrized[5]. With richer parameters such as dictionaries, lists, or callable objects, the auto-generated names degrade into opaque labels like test_my_bench[param0-param1]. They are hard to read, and if the underlying values change between runs, CodSpeed treats each case as a new benchmark and loses the historical comparison. Use the ids argument to attach a stable, descriptive label to each case:
tests/test_benchmarks.py
The benchmark output now reads test_fib_named[tiny] through test_fib_named[huge], which is easier to scan and stays stable even if you tweak the parameter values later. For finer-grained control, wrap individual cases in pytest.param to attach an id one at a time:
tests/test_benchmarks.py
This form is especially useful when parameters are dictionaries, dataclass instances, or other non-trivial objects that pytest cannot turn into readable ids on its own. pytest.param also works with multiple parameters. Pass one positional value per name in the parametrize declaration, then attach a single id that describes the whole case:
tests/test_benchmarks.py
The output now reads test_fib_algorithms[recursive-small] instead of the default test_fib_algorithms[recursive-10], so the benchmark name stays stable even if you later change 10 to 15 to keep run times in a useful range.
Pick ids that describe the scenario, not the raw value. ["cold-cache", "warm-cache"] tells you more about what is being measured than [0, 1], and it stays meaningful when the underlying values change.

Benchmarking Only What Matters

Sometimes, you have expensive setup that shouldn’t be included in your benchmark measurements. For example, generating large datasets, creating complex data structures, or preparing test data. This is where the benchmark fixture comes in. The benchmark fixture gives you precise control over what gets measured. Let’s benchmark a data analysis function that identifies outliers in numerical data. The expensive part is generating the test dataset, but we only want to measure the outlier detection algorithm:
tests/test_outlier_detection.py
The setup code (generating the dataset) runs once, and only the detect_outliers() call inside benchmark() is measured. This gives you accurate performance data without the noise of test setup. Run this benchmark by filtering the pytest command to this file:
You should see output like this:
terminal
The results reveal a crucial insight about what we’re actually measuring. Notice the dramatic difference between the two benchmark groups: Dataset generation (test_generate_dataset):
  • 10k elements: 124.29µs
  • 100k elements: 11.0ms (88x slower)
  • 1M elements: 225.5ms (1,814x slower than 10k)
Outlier detection (test_outlier_detection):
  • 10k elements: 46.01µs
  • 100k elements: 3.3ms (72x slower)
  • 1M elements: 132.4ms (2,878x slower than 10k)
This comparison shows that for the 1M element dataset, dataset generation takes 225.5ms while outlier detection takes 132.4ms, the setup is actually slower than the algorithm we want to measure. Without using the benchmark fixture to exclude the setup, our measurements would include both operations, making it impossible to understand the true performance of the outlier detection algorithm. The benchmark fixture ensures we measure only what matters: the algorithm itself, not the test infrastructure around it.

Additional Techniques

Marking an Entire Module

If you have a dedicated benchmarks file, you can mark all tests as benchmarks at once using pytest’s module-level marking:
tests/benchmarks/test_math_operations.py
Now all tests in this file are automatically benchmarked without individual decorators. This is useful for benchmark-specific test files.

Fine-Grained Control with Pedantic

For maximum control over your benchmarks, use benchmark.pedantic(). This allows you to specify custom setup and teardown functions, control the number of rounds and iterations, configure warmup behavior, and more:
tests/test_advanced.py
Here is the output when you run this benchmark:
terminal
We can see that as expected each benchmark ran 100 rounds of 10 iterations each, totalling 1,000 iterations. Using benchmark.pedantic() is especially useful for bigger benchmarks where you need precise control over rounds, iterations, and warmup behavior.

Benchmarking Async Functions

To benchmark asynchronous functions, we can use the benchmark fixture along with asyncio.run() on a synchronous sub-function that calls our async code. Here’s an example:
tests/test_async.py
Here is the output when you run this benchmark:
terminal
Since asynchronous functions most likely involve I/O operations, their execution time can vary significantly based on external factors like network latency or disk speed. When benchmarking async code, consider running more iterations or rounds to obtain reliable measurements.If you are using CodSpeed in your CI to run your benchmarks, be sure to use the Walltime instrument to get accurate timing for async operations.

Best Practices

Use Assertions to Verify Correctness

Since benchmarks are regular pytest tests, they should include assertions to verify correctness:
This ensures you’re benchmarking correct code, not broken code that happens to be fast. Or, as we briefly said in the introduction, you can turn existing tests into benchmarks by adding the @pytest.mark.benchmark decorator.

Keep Benchmarks Deterministic

Your benchmarks should produce consistent results across runs:

Benchmarking Your Own Package

Following Python best practices, your source code should live in a src/ directory. Here’s a typical project structure:
terminal
Your source code in src/mylib/algorithms.py:
src/mylib/algorithms.py
Then benchmark it in your tests:
tests/benchmarks/test_algorithm_performance.py
Make sure your package is installed in development mode:

Running Benchmarks Continuously with CodSpeed

So far, you’ve been running benchmarks locally. But local benchmarking has limitations:
  • Inconsistent hardware: Different developers get different results
  • Manual process: Easy to forget to run benchmarks before merging
  • No historical tracking: Hard to spot gradual performance degradation
  • No PR context: Can’t see performance impact during code review
This is where CodSpeed comes in. It runs your benchmarks automatically in CI and provides:
  • Automated performance regression detection in PRs
  • Consistent metrics with reliable measurements across all runs
  • Historical tracking to see performance over time with detailed charts
  • Flamegraph profiles to see exactly what changed in your code’s execution
For the full CodSpeed integration reference, see Writing Benchmarks in Python.

How to set up CodSpeed with pytest-codspeed

Here’s how to integrate CodSpeed with your pytest-codspeed benchmarks:
1

Set Up GitHub Actions

Create a workflow file to run benchmarks on every push and pull request.
Important: Use actions/setup-python to set up Python, not uv install. This is required for CodSpeed’s CPU simulation to work correctly.
2

Check the Results

Once the workflow runs, your pull requests will receive a performance report comment:Pull Request ResultPull Request Result
3

Access Detailed Reports and Flamegraphs

After your benchmarks run in CI, head over to your CodSpeed dashboard to see detailed performance reports, historical trends, and flamegraph profiles for deeper analysis.
Profiling Report on CodSpeed

Profiling Report on CodSpeed

Profiling works out of the box, no extra configuration needed!Learn more about flamegraphs and how to use them to optimize your code.

Next Steps

Check out these resources to continue your Python benchmarking journey:

Get Started with CodSpeed

Sign up and start tracking your Python performance in CI

CodSpeed Python Benchmarking Docs

Explore the full pytest-codspeed API reference

Choosing the Right Strategy

Learn when to use different Python benchmarking approaches

Performance Profiling

Learn how to use flamegraphs to optimize your code