Documentation
How flakytest works
Everything about the flakytest pytest plugin and CLI, and the optional cloud at flakytest.dev. In a hurry? The get started guide takes two minutes.
Overview
flakytest runs your pytest suite under a sampling profiler and explains, for every test, class and file, where the time goes: setup, the test itself and teardown, and inside those the database, password hashing, network calls, sleeps, fixtures, factories and imports, down to the line of your code that causes it. Then it ranks what to fix first, with the exact setting or line to change.
-
Local and free. The plugin needs no account. Every run writes an HTML report and a
profile.jsonto.flakytest/runs/. -
Made for agents too.
flakytest reportprints the same as Markdown, andflakytest mcpserves your runs to coding agents. - flakytest.dev is optional. With a project token, runs from every developer machine and CI job are collected, so you can compare environments, follow trends, and track flaky tests.
Install
$ pip install flakytest # or: uv add --dev flakytest $ pip install 'flakytest[py-spy]' # Python 3.11–3.14: adds the py-spy profiler
Install it into the environment your tests run in. It supports Python 3.11+ and pytest 6+. On Python 3.15+ flakytest uses tachyon, the sampling profiler in the standard library (profiling.sampling). On older versions it uses py-spy. Without either, you still get exact durations, fixture times and query counts, just no breakdown inside tests.
Run flakytest doctor to see which profiler will be used and whether it's allowed to attach (see troubleshooting).
Profile a run
$ flakytest run # pytest with profiling, then opens the report $ flakytest run -- tests/api -x # everything after -- goes to pytest $ pytest --flakytest-profile # the same from pytest (or FLAKYTEST_PROFILE=1 pytest)
At the end of the run pytest prints a summary:
============================== flakytest profile ===============================
812 tests in 1.4 min (test time 5.0 min) · local:anze@mbp · Python 3.15.0 · tachyon @ 1000 Hz
Time goes to: password hashing 41% · database 30% · test code 9% · sleep 6%
Slowest tests:
3.21s tests/test_orders.py::TestCheckout::test_checkout (database 70%, setUp (call) 2.1s, 412 queries)
Fix next:
1. [high] Use a fast password hasher in tests (~2.0 min per run)
2. [high] Create shared test data in setUpTestData instead of setUp (~41s per run)
Report: .flakytest/runs/20261016-210311-8c3f2a/index.html
Every run writes .flakytest/runs/<run id>/index.html (the report) and profile.json (everything in it, as data). .flakytest/ adds its own .gitignore, so runs never end up in git. Runs are identified by their directory name; latest and previous work wherever a run is expected.
The report
index.html is a single file that works offline. On the left is every test, grouped by file and class, with a bar showing how its time splits into categories. Filter by name, show failed, the slowest 10% or under-sampled tests, and sort by time or name. Click a test, class or file to see:
- Where the time goes: setup, call and teardown, and the time by category.
-
Biggest pieces of work: the top-level things each phase did: fixtures,
setUp, the test body, Django's transaction setup. - Hot lines in your code: the deepest line of your project in each sample, with its source and category. "Which line of mine is slow."
- Call tree and hot functions, with pytest and pluggy internals hidden.
- Fixtures: exact setup time of every fixture the test ran, and its scope. Class, module and session fixtures are charged to the test that triggered them, and marked as shared.
- Database: query count and time for Django and SQLAlchemy, and repeated queries (N+1).
- Fix next: the suggestions that apply here.
The session view adds a timeline (startup, collection, tests, teardown), the slowest imports during startup and collection, and fixture totals. Copy for agent copies what you're looking at as Markdown. flakytest serve serves all runs on localhost if you'd rather not open files.
CLI reference
| Command | What it does |
|---|---|
flakytest run
|
Run pytest with profiling. Opens the report in a browser when run interactively (not in CI). Options: --rate N, --backend, --open / --no-open; arguments after -- go to pytest.
|
flakytest report [run]
|
Print a run's report: where time goes, ranked fixes with file:line, the slowest tests. Options: --format markdown|json, --top N slow tests (15), --test ID for one test or a file/class prefix, with its call tree and hot lines.
|
flakytest compare [old] [new]
|
Compare two runs (default: previous and latest): tests that got faster or slower, and correctness: tests whose outcome changed and tests that stopped running. |
flakytest serve [run]
|
Serve the reports on localhost and open the latest. Options: --port N, --no-open.
|
flakytest list
|
List the runs in this project. |
flakytest upload [run]
|
Upload a run to flakytest.dev (needs FLAKYTEST_SECRET_TOKEN). Runs are uploaded automatically when the token is set; this is for runs made without it.
|
flakytest mcp
|
An MCP server (stdio) over your local runs, for coding agents. |
flakytest doctor
|
Check which profiler will be used and whether it can attach. |
The CLI looks for .flakytest/ in the current directory and its parents, so it works from anywhere in your project.
Options and environment
| pytest option | Environment variable | Default |
|---|---|---|
--flakytest-profile
|
FLAKYTEST_PROFILE=1
|
off |
--flakytest-rate N
|
FLAKYTEST_PROFILE_RATE
|
1000 (tachyon), 500 (py-spy) samples per second |
--flakytest-backend
|
FLAKYTEST_PROFILE_BACKEND
|
auto; or tachyon, py-spy, none (timings only)
|
--flakytest-output DIR
|
FLAKYTEST_PROFILE_DIR
|
<rootdir>/.flakytest/runs
|
| Environment variable | What it does |
|---|---|
FLAKYTEST_SECRET_TOKEN
|
Your project's token on flakytest.dev. Turns on uploads and flaky test tracking; without it nothing leaves your machine. |
FLAKYTEST_UPLOAD=0
|
Keep profiles local while still tracking flaky tests. |
FLAKYTEST_ENV
|
Name this environment yourself instead of the detected ci:github-actions:<job> or local:<user>@<host>.
|
FLAKYTEST_HOST
|
Where to upload (default https://flakytest.dev).
|
How profiling works
Attributing samples to tests
pytest runs every test phase from CallInfo.from_call. flakytest wraps it so each phase runs inside a tiny generated function named after the test and phase, like __flakytest_call_1f3a9c0d2b4e__ (the key is the first 12 characters of the SHA-1 of the test id). The profiler captures that frame in every stack it samples while the phase runs, so each sample belongs to exactly one test and phase. There's no clock to synchronise, and it works the same for tachyon, py-spy and every pytest-xdist worker.
Samples outside any test are classified by the pytest hooks on their stack: startup, collection, session teardown or other. The profiler starts before your conftest.py files are imported, so Django setup and slow imports are measured too.
Exact times, sampled reasons
pytest's durations stay the source of truth: a test that took 1.82s took 1.82s. Samples only decide how each phase's time is split. A stack's share of a phase is its sample count divided by the phase's samples, times the phase's duration. Fixture setup times are measured exactly with a pytest_fixture_setup wrapper. Database queries are counted per test through Django's execute_wrapper and SQLAlchemy's engine events.
Overhead
The profiler runs in a separate process and reads the test process's memory from the outside, so the tests themselves barely slow down. That's also why high rates are cheap with tachyon: if short tests get too few samples, raise the rate (--flakytest-rate 5000) and the report tells you when you should.
pytest-xdist
Each worker is sampled on its own and every test records the worker it ran on. One report covers the whole session, and it suggests a better distribution when workers finish far apart.
Categories
Every sample inside a test gets exactly one category: the first rule in this order that matches any frame of its stack. A phase that got no samples counts as unsampled.
| Category | Matches |
|---|---|
| gc | the garbage collector |
| imports | importlib |
| password hashing | Django's hashers, bcrypt, argon2, passlib, pbkdf2 |
| migrations | Django migrations, Alembic |
| DB schema / flush |
creating and flushing test databases, SQLAlchemy create_all / drop_all
|
| fixture loading | Django loaddata and serializers |
| browser | Selenium, Playwright |
| subprocess | subprocess, multiprocessing, testcontainers, docker |
| database | Django's ORM, psycopg, sqlite3, SQLAlchemy, asyncpg, PyMySQL, … |
| cache | Redis, Memcached, Django cache backends |
| network | sockets, ssl, http.client, urllib3, requests, httpx, aiohttp, botocore |
| sleep |
time.sleep, asyncio.sleep
|
| waiting |
Event.wait, Thread.join, Queue.get, select()
|
| templates | Django templates, Jinja2, Mako |
| test data factories | factory_boy, model_bakery, Faker, mixer, Hypothesis |
| mocking | unittest.mock, pytest-mock, freezegun, time-machine, responses |
| copying |
copy.deepcopy (Django's setUpTestData copies class attributes per test)
|
| serialization | json, pickle, pydantic, marshmallow, DRF serializers, YAML |
| logging | logging |
| test code / app code | the sample ended in a test file / in your project's code |
| pytest / libraries | pytest, pluggy and unittest internals / anything else |
Fix next
Suggestions are ranked by the time they'd save per run. Each one comes with the lines of your code that trigger it, the tests it affects, the fix, how to keep the tests correct while making it, and a link to the relevant docs. Severity is high when a fix saves 10% of test time or a minute per run, medium from 3% or 10 seconds, and low otherwise. flakytest checks for:
| Problem | Suggested fix |
|---|---|
| PBKDF2/bcrypt/argon2 password hashing |
a fast hasher in test settings, force_login instead of login
|
| Migrations on every run |
--reuse-db / --keepdb, --nomigrations
|
TransactionTestCase flushing every table
|
TestCase, which rolls back a transaction instead
|
Data built in setUp for every test
|
setUpTestData, once per class
|
| Expensive function-scoped fixtures | a wider scope, or an expensive shared part with a cheap reset |
SQLAlchemy create_all / drop_all per test
|
the schema once per session, a SAVEPOINT rollback per test |
| Repeated queries (N+1) |
select_related / prefetch_related, selectinload, assertNumQueries
|
| Fixtures loaded from files, deep copies of test data | factories or shared data, fewer and smaller class attributes |
time.sleep and waiting in tests
|
poll for the condition, patch the backoff, move the clock instead |
| Real network calls | responses / respx / pytest-httpx, vcrpy, pytest-socket |
| Redis or Memcached, Celery, subprocesses, browsers | an in-memory cache, eager tasks, direct calls, a shared browser |
| Factories and Faker |
setUpTestData, build(), bulk creation, one Faker instance
|
| freezegun, mock autospec | time-machine, patch once per class |
| Heavy imports, slow startup and collection |
testpaths, norecursedirs, imports out of module level
|
| GC pauses, logging, parsing data files |
gc.freeze() after startup, quieter logging in tests, parsing fixture files once
|
| A durable test database server, reconnecting in every test |
fsync=off and synchronous_commit=off on the test server, one connection pool per session
|
| Serial suites on multi-core machines, unbalanced xdist workers |
pytest -n auto, --dist worksteal
|
| pytest-cov's default tracer |
COVERAGE_CORE=sysmon
|
| Django debug settings in tests |
no debug toolbar, DEBUG=False, simple static files storage, eager Celery
|
| A new asyncio event loop per test | a session-scoped loop |
Warnings
-
undersampled: tests long enough to be sampled got no samples, or tests with fewer than 5 samples add up to 5% of test time. The warning recommends a rate that would give the median of those tests 10 samples. Rerun with
--flakytest-rate <rate>. - short_tests: tests shorter than one sample interval got no samples. Expected, and informational only.
- profiler_unavailable: no profiler could attach. Durations, fixture times and query counts are still exact; only the breakdown inside tests is missing.
- sample_errors: the profiler couldn't read a large share of its samples.
Memory
Every test's memory is measured in the process that runs it, from its setup to its teardown, at well under a millisecond per test. Under pytest-xdist each worker is its own process and runs one test at a time, so each worker measures its own tests. For every test the report shows:
- Needed: its peak resident memory (RSS) above what its process had when it started. This is the number to rank by.
- Kept: how much more memory its process had after it than before.
The report lists the tests that needed the most (the Most memory filter), each worker's peak and growth, and, on Linux, all workers added up over time: what the machine needs so CI doesn't run out of memory, and which tests were running at that moment. A process whose memory keeps growing over the run gets a memory_growth warning naming the tests it grew in. Typical causes are module-level caches and lists, lru_cache, signal receivers, and Django's connection.queries with DEBUG=True.
On flakytest.dev the dashboard lists the tests that need the most memory, with a Most memory tab on the tests page. Each run shows its peak and every worker's peak, test pages show what a test needs in each environment, and the environments table shows each environment's peak.
Limitations
-
Exact per-test peaks need Linux. There the kernel's peak counter (
VmHWM) is reset before every test through/proc/self/clear_refs. On macOS and Windows the peak can't be reset, so a test's peak is only known when it raised its process's peak; a test that needed 300 MB after an earlier one needed 400 MB shows none. Installingpsutiladds every test's memory at its start and end there. - Only the test processes are measured. Memory used by a database server, Redis, a browser or subprocesses isn't counted, and neither is the pytest-xdist controller process, which mostly waits.
-
Workers added up is an upper bound. It assumes each worker was at its test's peak for the whole test. On Linux it adds up private memory (
RssAnon), so the interpreter and shared libraries every worker maps count once; elsewhere it adds up RSS, which counts them once per worker. - "Kept" is noisy, peaks are reliable. Python and the C allocator often hold on to freed memory for reuse, so memory a test frees can still look kept, and later tests reuse it. A worker that keeps growing across many tests is the signal to look for.
- It varies between runs. With xdist, tests land on different workers every run, so a test's starting memory differs. flakytest.dev averages each test's peak over its runs.
Coding agents
The reports are written so an agent can act on them. Ask yours to make the suite faster without changing what it tests, and have it check its work:
$ flakytest report # where time goes, ranked fixes with file:line $ flakytest report --test tests/test_orders.py # one test, class or file, with its call tree $ flakytest run && flakytest compare # faster? and did any test break or stop running? $ claude mcp add flakytest -- flakytest mcp # or give it the local MCP server
flakytest compare flags tests whose outcome changed and tests that stopped running, so a speed-up that deletes, skips or breaks tests doesn't go unnoticed. The local MCP server has list_runs, get_report, get_test, list_slow_tests and compare_runs tools.
In CI
Run pytest --flakytest-profile (or set FLAKYTEST_PROFILE=1) in your test job. On GitHub Actions and other Linux runners the profiler can attach without extra setup. In Docker you may need --cap-add SYS_PTRACE. Each CI job becomes its own environment on flakytest.dev. To keep the report, save .flakytest/runs/ as a build artifact. See configure your CI for adding the token as a secret.
flakytest.dev: connect a project
Sign up, create a project and copy its secret token from the project's settings. With FLAKYTEST_SECRET_TOKEN set, every pytest run does two things:
- Reports results, profiled or not: every test's outcome and duration, for flaky test tracking and muting. Failure text and captured output are sent only for tests that didn't pass.
-
Uploads the profile when the run is profiled, unless
FLAKYTEST_UPLOAD=0.
$ export FLAKYTEST_SECRET_TOKEN=your-secret-token $ pytest --flakytest-profile
The token identifies the project, so keep it in a CI secret or your shell, not in the repository. A project can have a public slug in its settings, which makes its dashboard readable by anyone with the link.
Organizations
Projects belong to an organization, and everyone in it can see and manage all of its projects. Signing up creates one for you, named after your GitHub login or email; rename it, or create more (e.g. one per company), from the switcher at the top. A project's pages are at flakytest.dev/<organization>/<project>/.
- Members see every project, create projects, mute tests and manage tokens. Owners also invite and remove people, change roles, and rename or delete the organization. An organization always has at least one owner.
- Invitations go out by email from the organization's Members page. The link works once, for 14 days; someone without an account signs up and joins in one go.
- Public projects are readable by anyone with the link, which suits open source projects. Turn it on in the project's settings; settings and tokens stay private.
Environments
Runs are grouped by environment, and every page can be filtered by one. Each CI job and each developer machine is its own environment, with its own trends:
-
ci:github-actions:<job>,ci:gitlab:<job>,ci:circleci:<job>,ci:buildkite:<label>,ci:jenkins:<job>, orcion other CI systems that setCI=true. -
local:<user>@<host>on developer machines.
Set FLAKYTEST_ENV to choose the name yourself, for example to keep runs on differently sized CI runners apart or to group a team's laptops.
Dashboard and pages
- Overview: suite wall time and test time per run for each environment (click a point to open the run), where the time goes per environment, the slowest tests and why, the tests that need the most memory, fix next across the latest run of each environment, CI vs local (the tests that are much slower in CI, and the category that explains it), failing and flaky tests, and every environment that reported. Filter by environment and by the last 24 hours, 7, 30 or 90 days.
- Tests: every test with its average duration, its share of the suite and its top category. A memory tab ranks tests by the memory they need, and a flaky tab lists tests by failure classification. Search by name.
- A test: average, median and max per environment, phases and categories, a duration chart per run (failed runs in red), the latest call tree, hot lines, fixtures and database queries, its reliability, and its history with links to each run.
- Runs and profiles: every session's pass/fail results, and every profiled run with its full breakdown, insights and warnings.
Averages and trends are kept per test, environment and day, so pages stay fast however many runs a project has.
Flaky tests
A failure streak is a run of consecutive failing results of one test (skips are ignored). flakytest.dev classifies every streak by how it ended:
- Flaky: the test both failed and passed on the same commit. Same code, different result: noise from timing, ordering or shared state. Runs from a dirty working tree never count as the same commit, so local edit-and-rerun loops aren't mistaken for flakiness.
- Regression caught: the streak ended with a passing run that didn't change the test's file. Application code was fixed: the test did its job.
- Test changed: the streak ended with a passing run that changed the test's file. The test was edited to pass, either an intended behaviour change or a brittle test.
- Ongoing (still failing) and resolved (fixed on another commit by a client too old to report changed files).
Results are counted per environment, so you can see whether a test fails only in CI or only on someone's laptop.
Muting
Mute a test from its page on flakytest.dev, for a week by default. A muted test still runs and is still reported, but its failure doesn't fail your build. At the start of a session the plugin fetches the muted tests, including on pytest-xdist workers. See mute a test.
MCP server
flakytest.dev serves your project's data to coding agents over MCP, authenticated with the project's token:
$ claude mcp add --transport http flakytest https://flakytest.dev/mcp \
--header "Authorization: Bearer $FLAKYTEST_SECRET_TOKEN"
Tools: list_environments, get_overview, list_slow_tests (with the reason for each), list_memory_tests, get_test (phases, fixtures, hot lines, memory, flakiness), compare_environments, list_flaky_tests, get_insights and list_runs. Ask things like "why is CI slower than my laptop?" or "which flaky tests should I fix first?".
Uploads
- In parts. A profile is uploaded gzip-compressed, split into parts of up to 2,000 tests, so even very large suites never need one huge request. Each part carries the run's summary and only the frames its tests use.
- Processed in parallel. flakytest.dev processes the parts in parallel. Per-test pages update as parts land; the run counts in averages and trends once all its parts are in. A run that's still missing parts an hour after its last one arrived is marked incomplete. It's listed with the tests that did arrive, but left out of suite trends.
- Safe to retry. Uploads are idempotent: the same part again changes nothing, and a changed part replaces the old one. An upload request never waits for processing.
-
Your tests aren't affected. Upload failures are logged and never fail your test run.
flakytest uploadsends a run again later.
What's uploaded
Nothing is sent without FLAKYTEST_SECRET_TOKEN. With it, flakytest.dev receives:
- Results: test ids, outcomes and durations. Keywords, failure text and captured stdout, stderr and logs are sent only for tests that didn't pass, truncated to 50,000 characters each.
-
Session: the Python version, installed package names and versions, a fixed list of CI variables (run ids, branch, job name), the git branch, commit and changed file names, and the environment name, which includes your user and host name unless you set
FLAKYTEST_ENV. -
Profiles:
profile.json: file paths relative to the project, function names and line numbers, the source lines of the hot spots in your code, the normalized SQL of repeated queries, with values replaced by placeholders, and each test's memory figures.
Set FLAKYTEST_UPLOAD=0 to keep profiles local while still tracking flaky tests. See the privacy policy.
Retention
- Uploaded profile documents: 14 days, except the newest run of each environment, which is kept.
- Per-run results and profile rows: 90 days, the longest window the pages offer.
- Daily per-test statistics behind trends: 400 days.
- The latest breakdown of every test in every environment is always kept.
Profile format
profile.json is a documented, versioned format: run metadata, a summary, interned frames and stacks, and per test its phases, samples, categories, segments, hot lines, fixtures and database stats, plus the insights and warnings. Consumers ignore keys they don't know. See docs/profile-format.md.
Troubleshooting
-
Start with
flakytest doctor. It shows the Python version, whether tachyon and py-spy are available, which one will be used, and on Linux the ptrace setting. -
No breakdown inside tests. The profiler couldn't attach. Sampling profilers read another process's memory: on Linux flakytest lets its own profiler attach (
prctl(PR_SET_PTRACER)), which is enough with the defaultptrace_scope=1, including GitHub Actions. Withptrace_scope=2or in Docker, add--cap-add SYS_PTRACE. On macOS profilers need root:sudo -E flakytest run. On Python 3.11–3.14, install py-spy. -
Short tests have no breakdown. Raise the rate, e.g.
--flakytest-rate 5000, as the undersampled warning suggests. -
Nothing shows up on flakytest.dev. Check that
FLAKYTEST_SECRET_TOKENis set where pytest runs (CI secrets often aren't passed to forks), thatFLAKYTEST_UPLOADisn't 0, and the end of pytest's output for upload errors. See troubleshooting. - Still stuck? Email [email protected] or open an issue on GitHub.