coverage
Coverage.py records which lines of your Python code actually executed, usually while your test suite runs. It hooks into the interpreter (the classic C tracer, or sys.monitoring on Python 3.12+), watches every line as it runs, and stores the results in a .coverage SQLite file. From that file it prints a terminal table, writes an annotated HTML site, or emits XML, JSON, LCOV, and Markdown for CI tools. It also parses your source to know which lines were executable in the first place, so it can tell you what never ran at all, and with branch mode on it tracks which way each if and loop actually went. Nearly every Python coverage number you have ever seen came out of this library, including the ones pytest-cov and Codecov report.
The definitive Python coverage engine, maintained carefully by one person for well over a decade and correct in the corners that matter. Install pytest-cov rather than this directly if you use pytest, and treat the percentage as a map of untested code rather than a score to defend.
Use it if
- You want to find code your tests never touch: the HTML report highlights unexecuted lines file by file, which is far more useful than the single percentage at the bottom
- You need a coverage gate in CI: fail_under plus a machine-readable report (coverage xml, coverage lcov, coverage json) is what Codecov, Coveralls, SonarQube, and most GitHub Actions expect as input
- Your tests spawn subprocesses or run under pytest-xdist and you need the numbers merged: parallel mode plus coverage combine is the only supported way to stitch several .coverage files together
- You want branch coverage, not just line coverage: turning on branch = true catches the if with no else and the loop body that never ran twice
- You are on pytest and just want a coverage flag: install pytest-cov instead. It wraps this same library but also sets up subprocess measurement, plays correctly with pytest-xdist, and gives you --cov without a two-step run-then-report dance
- You care about test suite speed on every local run: the C tracer typically adds meaningful runtime to a test suite because it fires a callback per line. Python 3.12+ users can set COVERAGE_CORE=sysmon to cut that cost, but on older interpreters you are paying it every run
- You think a high number means the code is tested: coverage proves a line executed, not that anything asserted on its behaviour. A suite with zero assertions can hit 100 percent, and teams that gate on a percentage tend to write tests that touch lines rather than tests that check outcomes
- You need coverage across languages or into C extension code: this measures Python lines only. Compiled extensions, template languages, and JS in your project are invisible to it, so a monorepo gate needs a second tool anyway
- You want per-test attribution out of the box: dynamic contexts exist but are opt-in, blow up the size of the data file, and the HTML view of them is functional rather than pleasant
Setup reality
pip install coverage, no compiled dependency you need to care about (wheels ship the C tracer prebuilt, and there is a pure-Python fallback). The friction starts after install. Running coverage run -m pytest measures only the parent process, so subprocess and pytest-xdist work needs parallel = true, a coverage combine step, and often a sitecustomize hook via COVERAGE_PROCESS_START. Branch coverage is off by default. Without source or include set, you measure site-packages too and the report is noise. In Docker or CI where the build path differs from the report path, you need relative_files = true plus a [paths] section or every file shows as missing. Config lives in pyproject.toml under [tool.coverage.*], or .coveragerc, or setup.cfg, and having two of them in one repo is a classic afternoon lost.
Patterns
Measure a test run and print the tablerun-and-report
coverage run -m pytest
coverage report -m
# Name Stmts Miss Cover Missing
# --------------------------------------------------
# app/billing.py 84 9 89% 40-44, 71-74
# TOTAL 310 22 93%-m shows the missing line numbers, which is the only part of the table worth reading. This measures the parent process only, so anything your tests launch with subprocess is not counted yet.
Configure coverage in pyproject.tomlconfigure-pyproject
[tool.coverage.run]
source = ["myapp"]
branch = true
parallel = true
[tool.coverage.report]
fail_under = 85
show_missing = true
skip_covered = true
exclude_also = [
"if TYPE_CHECKING:",
"raise NotImplementedError",
]source limits measurement to your package so site-packages does not pollute the report. exclude_also (7.2+) adds to the built-in exclusion list; the older exclude_lines replaces it, which silently drops the default pragma rule.
Turn on branch coveragebranch-coverage
coverage run --branch -m pytest
coverage report -m
# Name Stmts Miss Branch BrPart Cover Missing
# app/auth.py 52 0 18 3 96% 31->34, 40->exit31->34 means the jump from line 31 to 34 never happened, so one side of that condition is untested. Branch mode is off by default and usually drops your percentage by a few points the first time you enable it.
Merge coverage from parallel or subprocess runscombine-parallel-runs
# with parallel = true in config
coverage run -m pytest -n 4 # pytest-xdist workers
coverage combine # merges .coverage.<host>.<pid>.<rand>
coverage report -mEach process writes its own suffixed data file; combine folds them into one .coverage and deletes the parts. Forgetting combine gives a confusing 'No data to report' error even though the files are sitting right there.
Cover code that runs in a spawned subprocessmeasure-subprocesses
# 1. install the startup hook once per environment
python -m coverage debug sys | grep -i sysmon # optional sanity check
# 2. in your test env
export COVERAGE_PROCESS_START=$PWD/pyproject.toml
# 3. in sitecustomize.py on sys.path:
import coverage
coverage.process_startup()Without both the env var and the process_startup() call, subprocess code shows as 0 percent covered. parallel = true is required too, otherwise the children overwrite each other's data file.
Generate the annotated HTML reporthtml-report
coverage html -d htmlcov
python -m http.server -d htmlcov 8000
# or straight to a percentage for a badge
coverage json -o coverage.json
python -c "import json;print(json.load(open('coverage.json'))['totals']['percent_covered_display'])"The HTML view is the actual product here: red lines are unexecuted, and with branch mode on, partially taken branches are marked separately. Add htmlcov/ to .gitignore before your first commit.
Emit XML or LCOV for CI servicesci-machine-readable
coverage run -m pytest
coverage combine || true
coverage xml -o coverage.xml # Codecov, Sonar, Jenkins
coverage lcov -o coverage.lcov # Coveralls, VS Code gutters
coverage report --fail-under=85Generate the report files before the fail_under check, otherwise a failing gate exits the job and your coverage service gets no upload to show the diff on.
Make paths match between build and report machinesfix-ci-paths
[tool.coverage.run]
relative_files = true
[tool.coverage.paths]
source = [
"src/",
"/app/src/",
"*/site-packages/myapp/",
]This is the fix for a report full of files at 0 percent, or 'No source for code' errors, after running tests in Docker or tox. coverage combine rewrites the first-listed path over the others.
Exclude code you deliberately do not testexclude-code
def debug_dump(obj): # pragma: no cover
print(repr(obj))
if sys.platform == "win32": # pragma: no cover
import winregThe pragma comment excludes the whole block it introduces, not just that line. Keep these rare: every pragma is a line nobody will ever be told is untested again.
Cut measurement overhead on Python 3.12+speed-up-with-sysmon
COVERAGE_CORE=sysmon coverage run -m pytest
# check which core is active
coverage debug sys | grep -i coresys.monitoring lets the interpreter skip instrumenting code you are not measuring, which is much cheaper than the per-line C tracer. It needs Python 3.12 or newer, and some dynamic context and plugin features behave differently under it.
Record which test covered which linedynamic-contexts
[tool.coverage.run]
dynamic_context = "test_function"
[tool.coverage.html]
show_contexts = trueThe HTML report then lets you click a line and see the tests that executed it, which is how you find the one test holding up a whole module. It makes the .coverage file much larger and slows the run further.
Drive coverage from Python instead of the CLIpython-api
import coverage
cov = coverage.Coverage(source=["myapp"], branch=True)
cov.start()
import myapp.tasks
myapp.tasks.run()
cov.stop()
cov.save()
percent = cov.report(show_missing=True)Anything imported before cov.start() has its module-level lines counted as missed, so import your target inside the measured section. Use this for custom runners only; for normal test suites the CLI handles more edge cases.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| pytest-cov | PyPI | You run pytest and want coverage as a flag, with subprocess and xdist handling already wired up; it uses coverage.py underneath. |
| slipcover | PyPI | Coverage overhead is hurting a large suite and you can accept a younger tool with a smaller feature set in exchange for much lower slowdown. |
| diff-cover | PyPI | You inherited a legacy codebase and want to gate only the lines changed in a pull request instead of the whole project percentage. |