Hypothesis reports a failure and the counterexample is a list of two hundred dictionaries. Somewhere in it is a single value that triggers the bug, and the shrinker — whose whole job is to find that value — gave up. A stalled shrink is almost never a Hypothesis problem; it is a signal that the strategy or the property is shaped in a way that hides information from the search.
Prerequisites
- hypothesis 6.90+.
- Strategy composition from composing strategies with flatmap and @composite.
Solution
Three causes account for nearly every stall, and each has a structural fix.
1. Rejection sampling. assume() and .filter() discard whole examples. The shrinker generates a simpler candidate, the filter rejects it, and the shrinker learns nothing — so it keeps the larger input. Build the constraint into the draw instead:
from hypothesis import strategies as st
# Stalls: most simplifications are rejected, so the counterexample stays large.
pairs = st.tuples(st.integers(), st.integers()).filter(lambda t: t[0] < t[1])
# Shrinks cleanly: every draw is valid by construction.
@st.composite
def ordered_pair(draw):
low = draw(st.integers(min_value=-1000, max_value=999))
high = draw(st.integers(min_value=low + 1, max_value=1000))
return low, high
2. Draw order that hides structure. The shrinker simplifies draws roughly in the order they were made, so a strategy that draws contents before shape gives it nothing to pull on. Draw the size first, then the elements:
@st.composite
def rows(draw):
n = draw(st.integers(min_value=0, max_value=50)) # shrinks toward 0 first
return [draw(st.integers(0, 10_000)) for _ in range(n)]
3. A non-deterministic property. Shrinking works by re-running the property against smaller inputs; if the property sometimes passes on an input that previously failed, the shrinker concludes the smaller input is fine and backs off. Hypothesis usually detects this and reports Flaky, but a property that depends on the clock, a shared cache or dict ordering can stall silently before that check fires.
Why this works
The shrinker operates on the sequence of choices a strategy made, not on the final value. It repeatedly proposes a lexicographically smaller choice sequence and re-runs the property; a proposal that still fails becomes the new best, and one that passes — or is rejected by a filter — is discarded. Two consequences follow directly. A filter that rejects most proposals starves the search of successful steps, and a property whose outcome varies makes previously-failing proposals look successful. Draw order matters because the sequence is simplified from the front: an early draw that controls size lets one successful step remove many elements at once.
Measuring whether shrinking worked
--hypothesis-show-statistics reports the shrink phase directly, and the numbers say whether the search was productive:
$ pytest tests/test_totals.py --hypothesis-show-statistics -q
- during shrink phase (2.31 seconds):
- Typical runtimes: < 1ms
- 412 shrinks succeeded, 1897 shrinks failed
- Highest target scores: none
A high ratio of failed to successful shrinks with a large final counterexample is the signature of a blocked search. The same output reports invalid examples from the generate phase, which is the direct measure of how much rejection sampling is costing:
- during generate phase (0.94 seconds):
- 100 passing examples, 1 failing example, 863 invalid examples
Eight hundred invalid examples for a hundred valid ones means the strategy is rejecting nearly nine draws in ten. That number is both the runtime problem and the shrinking problem, and rebuilding the strategy fixes both at once.
Making the counterexample readable
A shrink that succeeds still produces an unreadable report when the strategy returns opaque objects. Hypothesis prints the repr of what the strategy produced, so a domain object without a meaningful repr yields <Order object at 0x7f...> and the reader learns nothing.
Two fixes. Give the object a faithful repr — a dataclass gets one for free — or have the strategy return the primitive draws alongside the object so the report shows the inputs that built it:
@st.composite
def order_case(draw):
quantity = draw(st.integers(min_value=0, max_value=100))
price = draw(st.integers(min_value=0, max_value=10_000))
# Returning the primitives makes the failing report self-explanatory.
return {"quantity": quantity, "price": price, "order": Order(quantity, price)}
The same principle applies to st.builds over a class you do not control: wrap it so the report names the arguments rather than the object.
Rewriting a strategy that will not shrink
The general repair has three moves, applied in order. Working through a concrete example makes the pattern reusable.
Start with a strategy that stalls: it generates a list of order lines, filters out invalid combinations, and derives the total afterwards.
from hypothesis import strategies as st
# Stalls: two filters and a derived value the shrinker cannot simplify.
bad = st.lists(
st.tuples(st.integers(), st.integers()), min_size=1
).filter(lambda rows: all(q > 0 for q, _ in rows)) \
.filter(lambda rows: sum(q * p for q, p in rows) < 10_000)
Move one: replace filters with bounded draws. Both filters express constraints that can be built into the draw ranges, so no candidate is ever rejected.
Move two: draw the shape before the contents. The list length becomes an explicit early draw, so the shrinker can shorten the list in one step rather than element by element.
Move three: return data whose repr explains the failure. A list of tuples prints acceptably; a list of domain objects would not.
@st.composite
def order_lines(draw, max_lines: int = 8, budget: int = 10_000):
n = draw(st.integers(min_value=1, max_value=max_lines)) # shape first
remaining = budget
rows = []
for _ in range(n):
qty = draw(st.integers(min_value=1, max_value=20)) # positive by construction
max_price = max(1, remaining // qty)
price = draw(st.integers(min_value=1, max_value=max_price))
remaining -= qty * price # budget respected by design
rows.append((qty, price))
return rows
Every draw is valid, the shape shrinks first, and the counterexample is a short list of small integers. The statistics confirm it: invalid examples drop to zero and the shrink phase reports a handful of successful steps rather than thousands of failed ones.
The cost is that the strategy is longer and encodes domain rules. That is the right trade — those rules were previously expressed as filters, where they cost runtime and blocked the search, and they are now expressed as bounds, where they cost nothing.
Edge cases and failure modes
Flakyerrors are the honest version of a stall. When Hypothesis reports one, fix the non-determinism rather than suppressing the check.- A
target()call changes the search but not the shrink. Targeted property-based testing guides generation toward a metric; it does not make a blocked shrink succeed. - Recursive strategies need explicit size bounds.
st.recursivewithoutmax_leavescan produce structures whose shrink space is enormous, and the phase times out before it finishes. - Shrinking respects the deadline. A property with a per-example deadline near its actual runtime will have shrink attempts killed, which looks like a stall and is a timeout.
- Stateful tests shrink the rule sequence, not just the data. A stalled state-machine shrink usually means one rule has a precondition that rejects most simplified sequences — see debugging RuleBasedStateMachine failures.
- Large
max_examplesdoes not compensate. Budget helps a search that is making progress; it does nothing for one that cannot take a successful step. A final measurement habit: record the size of the reported counterexample, not just whether the test failed. A property whose counterexamples grow over releases is a property whose strategy is drifting toward rejection sampling — usually because constraints were added as filters rather than as bounds. Noticing that trend early is far cheaper than rewriting the strategy after it has stopped producing readable failures.
Frequently Asked Questions
Why is my counterexample a 300-element list? Because something blocked the shrinker: usually a filter that rejects the simpler candidates it tries, or a property that fails non-deterministically so a smaller failing input looks like a pass.
Does assume() hurt shrinking?
Yes, when it rejects a large fraction of draws. Every rejected candidate is a shrink attempt that produced no information, so heavy use of assume turns shrinking into a random walk.
Can I make Hypothesis shrink harder?
Increasing max_examples gives the shrinker more budget, but the usual problem is structural rather than budgetary. Rebuilding the strategy so invalid values cannot be drawn is far more effective than more attempts.
Does target() help a stalled shrink?
No. Targeted property-based testing biases generation toward inputs that maximise a metric, which finds different bugs; shrinking is a separate phase with its own rules. A stalled shrink is fixed in the strategy, not in the search objective.
Should I ever accept a large counterexample? Only briefly. A large counterexample still identifies a real bug, so fix the bug — but treat the size as a defect in the strategy and address it before the next failure, because the next one will be equally unreadable and may not be as easy to interpret.
Related
- Composing strategies with flatmap and @composite — draw order and construction over rejection.
- Advanced property-based testing with Hypothesis — the property shapes that shrink well.
- Reproducing Hypothesis failures with @example — pinning the minimal example once you have one.
- Reducing Hypothesis test execution time in CI — the runtime half of the same rejection problem.
← Back to Advanced Property-Based Testing