mrkeyoor.com_
Thu 06 Aug 00:57 UTC
PyPITestingupdated 05 Aug 2026

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.

Verdict

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.

API stability5/5The CLI, config keys, and the Coverage() Python API have been stable across the whole 7.x line; new features arrive as opt-in settings like exclude_also and sys.monitoring support rather than as breaking renames.
Docs5/5coverage.readthedocs.io documents every config option with its default, and has dedicated pages for subprocess measurement, contexts, and CI setup, which are exactly the places people get stuck.
Maintenance4/5Pushed August 2026, 259 open issues (298 counting PRs), regular releases through 7.15.x with Python 3.15 rc1 and free-threading already supported. Ned Batchelder has run it alone for years, so the bus factor is one, offset by Tidelift funding.
Ecosystem5/5Roughly 78 million weekly downloads and effectively the standard format: pytest-cov, Codecov, Coveralls, SonarQube, and editor gutter plugins all read its data or its XML and LCOV output.

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
Skip it if

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->exit

31->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 -m

Each 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=85

Generate 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 winreg

The 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 core

sys.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 = true

The 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

PackageRegistryPick it when
pytest-covPyPIYou run pytest and want coverage as a flag, with subprocess and xdist handling already wired up; it uses coverage.py underneath.
slipcoverPyPICoverage 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-coverPyPIYou inherited a legacy codebase and want to gate only the lines changed in a pull request instead of the whole project percentage.