A line-coverage report that says 100% can still hide an untested path. if discount: price -= discount executed with a non-zero discount marks both lines as covered, while the case where no discount applies — the implicit else — never ran. Branch coverage measures each possible transition out of a decision, and it reports that missing path as a partial branch. It is the same tool, one configuration line away, and it measures something much closer to what tests are meant to establish.
Enabling it usually drops the reported percentage by several points, which feels like a regression and is actually the measurement becoming honest. The drop is also informative in its own right: its size is a rough measure of how much of the suite's apparent thoroughness came from executing lines rather than exercising decisions, and the list of partial branches it produces is a ready-made backlog of the specific paths no test has ever taken. Handling that transition well — resetting the threshold from the new baseline, then working through the untaken branches that matter — is most of what adopting it involves.
Prerequisites
pytest-cov >= 5.0andcoverage >= 7.4.- Existing coverage enforcement, as in coverage measurement and enforcement.
Solution
# pyproject.toml
[tool.coverage.run]
branch = true # measure arcs between lines, not just lines
source = ["src/myapp"]
[tool.coverage.report]
show_missing = true
fail_under = 84 # measured branch baseline, not the old line number
pytest --cov --cov-report=term-missing -q
Name Stmts Miss Branch BrPart Cover Missing
----------------------------------------------------------------------
src/myapp/pricing.py 42 0 18 3 95% 17->19, 31->exit, 38->36
src/myapp/refunds.py 28 2 10 4 84% 22-23, 14->16, 26->exit
----------------------------------------------------------------------
TOTAL 412 11 118 19 91%
The Missing column now contains two kinds of entry. Plain numbers are lines never executed. Arrows are untaken branches: 17->19 means the transition from line 17 to line 19 never happened, and 31->exit means the function never returned early from line 31.
Why this works
With branch = true, coverage records arcs — pairs of (source line, destination line) — as the program runs, rather than only the set of lines executed. It also computes, from the compiled code, every arc that is possible. A decision point with two possible destinations where only one was observed is a partial branch, and the unobserved arc is what the report lists.
That is why the percentage drops. The denominator grows to include every possible branch outcome, and code that looked fully covered by lines turns out to have outcomes no test ever produced. None of the code changed; the question being asked got sharper. Line coverage asks whether each statement ran at least once; branch coverage asks whether each decision went every way it can. The second question is the one tests are actually meant to answer, which is why the stricter number is the more useful one to enforce.
Edge cases and failure modes
- Loops that always iterate.
for item in itemswhereitemsis never empty reports the loop's exit-on-first-check arc as untaken. That is a real gap — the empty case — and usually worth a test. while Trueloops. The arc for the loop condition being false is impossible and is excluded automatically in recent coverage versions; older versions report it spuriously.- Defensive branches. An
else: raise AssertionError("unreachable")is untaken by design. Mark it# pragma: no branchwith a reason rather than lowering the threshold. - Comprehension internals. Conditions inside comprehensions and generator expressions are measured too, which can surface unexpected partial branches in otherwise simple code.
- Keeping the old threshold. Carrying a 90% line threshold into branch mode fails the build immediately. Measure first, then set.
Reading the arrow notation
The branch entries in the Missing column are compact and easy to misread, so it is worth decoding the three common forms once.
17->19 means the arc from line 17 to line 19 was never taken. Usually line 17 is an if and 19 is the first line after its body, so this is the "condition was false" path. 31->exit means that from line 31 the function could have returned — through an early return, or by falling off the end — and never did in any test. 38->36 points backwards and almost always describes a loop: from the last line of the body, control could have gone back to the loop header at 36, and never did, which means the loop only ever ran one iteration.
The HTML report makes the same information visual, highlighting partially covered lines in a different colour and showing the missing destinations on hover. For working through a module's gaps it is considerably faster than the terminal output, and --cov-report=html alongside term-missing gives both without a second run.
A useful habit when adding a test for a partial branch is to check the specific arc disappears from the report afterwards. It confirms the new test actually takes the path intended — surprisingly often, a test written for the "no discount" case turns out to pass a discount of zero through a different branch entirely, leaving the original arc untaken and the gap unfilled.
Where untaken branches matter most
Not every partial branch deserves a test. A branch in a logging helper that only fires when a debug flag is set is low risk; a branch in refund calculation that only fires when the amount is zero is not. Working through the report by risk rather than by file order gets most of the value from a fraction of the effort.
The highest-value branches are usually in three places. Error handling, where the except path or the validation failure path is exactly what production exercises during an incident and tests exercise least. Money, dates and units, where the untaken branch is often a boundary — zero, negative, the last day of the month — and the defect is an off-by-one. Authorisation checks, where the untaken branch is the one that denies access, and a bug there is a security issue rather than a wrong result.
Adding tests for those branches first, and only then treating the threshold as the target, means the percentage rises in the places where it corresponds to real risk reduction. Chasing the number directly tends to produce tests for the easiest untaken branches, which are usually the least important ones.
Migrating a threshold without a fight
The practical obstacle to adopting branch coverage is not technical but social: the number drops, a gate that was green goes red, and somebody proposes turning it back off. A three-step migration avoids that conversation.
Measure first, in a branch, without touching the threshold. Record both numbers — the old line percentage and the new branch percentage — and the list of partial branches. That list is the useful artefact; the number is just its summary.
Then switch the configuration and set fail_under to the measured branch baseline, rounded down by a point. The gate stays green, nothing about enforcement has loosened, and the measurement is now stricter than it was.
Finally, raise the threshold as risky branches get tests, one or two points at a time. Pairing each raise with the tests that justified it keeps the number honest: it only goes up when coverage genuinely improved, rather than because somebody wrote a test for an unimportant branch to make the gate pass.
Frequently Asked Questions
Why does my coverage percentage drop when I enable branch coverage? Because branch coverage counts things line coverage ignores: every conditional has two outcomes, and each untaken outcome is now reported. The code did not get worse; the measurement got more honest. Reset the threshold to the new baseline and raise it deliberately.
What is a partial branch?
A line containing a decision where only some outcomes were exercised — an if whose body always ran but whose implicit else never did, or a loop that always iterated but never exited on its first check. The report lists the missing destination line numbers.
Should the threshold be the same for branch and line coverage? No. Branch coverage is systematically lower for the same code because it is a stricter measure. A 90% line threshold corresponds roughly to a mid-80s branch threshold on typical code; set the branch threshold from a measured baseline rather than copying the line number.
Related
- Coverage Measurement and Enforcement — thresholds, reports and CI gating.
- Enforcing Diff Coverage on Pull Requests — applying branch coverage to changed lines only.
- Combining Coverage Across a Python Version Matrix — branches that only run on one interpreter.
- Writing Metamorphic Properties — generating inputs that reach the untaken branches.
← Back to Coverage Measurement and Enforcement