As systems grow in complexity, the combinatorial explosion of valid input states renders example-based testing insufficient for guaranteeing correctness. The symptom is familiar: a suite that passes for months, then a production incident traces back to an input nobody wrote a test for. Hypothesis attacks that failure mode by generating inputs, enforcing invariants, and applying delta-debugging to failures — turning a brittle verification script into a generative validation engine. This guide grounds the property-based and fuzz testing approach in concrete execution models: strategies, the @given decorator, the shrinking engine, the example database, and CI-ready settings.
Prerequisites
- Python 3.10+ (3.9 is end-of-life as of October 2025) with type hints enabled.
hypothesis>=6.100andpytest>=8.0installed in the active virtual environment.- Familiarity with
pytestfixture lifecycles and basic decorators. - Optional:
sqlalchemyfor the database-integration example below.
Core concept
Hypothesis replaces explicit input-output pairs with properties — invariants that must hold for every valid input. Instead of asserting f(5) == 10, you assert for all x in Domain, property(f(x)). Properties are algebraic or structural: idempotency (f(f(x)) == f(x)), commutativity (f(a, b) == f(b, a)), or round-trip preservation (decode(encode(x)) == x). Hypothesis generates structured, type-aware inputs that probe boundaries human engineers rarely anticipate, and — crucially — guarantees deterministic reproduction of any failure it finds.
Three subsystems make that guarantee hold, and understanding them is what separates a productive Hypothesis user from a frustrated one:
- The generation engine. Every strategy draws from an internal buffer of bytes rather than calling
randomdirectly. That indirection is what makes runs replayable: fix the byte source and the same inputs reappear on every machine. - The shrinking engine. When a property fails, Hypothesis does not report the raw random input. It runs a delta-debugging pass that repeatedly simplifies the failing input — dropping list elements, shrinking integers toward zero, trimming strings — while re-checking that the failure survives. What you see is a minimal counterexample: the smallest input that still breaks the property.
- The example database. Minimized failures are written to a local
.hypothesis/examples/store keyed by a hash of the test. On the next run those stored inputs are replayed first, so a regression resurfaces immediately instead of waiting for the generator to rediscover it by chance.
These three pieces compose the lifecycle below, and every later section — settings, assume(), seeds — is really about tuning one of them.
Step-by-step implementation
Step 1 — Write a property with @given
Strategies live in hypothesis.strategies (aliased st) and are lazy generators — they describe how to produce data rather than producing it eagerly. The @given decorator binds strategies to a test, generates an example, injects it, and repeats up to max_examples (default 100).
from hypothesis import given, settings
import hypothesis.strategies as st
@given(st.text(min_size=1, max_size=50))
@settings(max_examples=200)
def test_utf8_round_trip(raw_text: str) -> None:
"""Encoding then decoding UTF-8 must preserve the original string."""
decoded = raw_text.encode("utf-8").decode("utf-8")
assert decoded == raw_text # round-trip invariant
assert len(raw_text.encode("utf-8")) >= len(raw_text) # bytes >= chars
pytest's assertion rewriting applies automatically, so failures include the exact generated input and intermediate state without manual logging.
Step 2 — Compose a custom strategy
@st.composite turns a function into a strategy that draws correlated fields and enforces cross-field rules before returning an object.
from dataclasses import dataclass
from datetime import datetime
from hypothesis import given, strategies as st
@dataclass
class UserEvent:
user_id: int
timestamp: datetime
action: str
metadata: dict
@st.composite
def valid_user_events(draw: st.DrawFn) -> UserEvent:
action = draw(st.sampled_from(["login", "purchase", "logout"]))
# Cross-field constraint: login events must carry a session_id
if action == "login":
metadata = draw(st.fixed_dictionaries({"session_id": st.uuids()}))
else:
metadata = draw(st.dictionaries(st.text(), st.integers()))
return UserEvent(
user_id=draw(st.integers(min_value=1, max_value=100_000)),
timestamp=draw(st.datetimes(min_value=datetime(2020, 1, 1))),
action=action, metadata=metadata,
)
@given(valid_user_events())
def test_event_has_required_fields(event: UserEvent) -> None:
if event.action == "login":
assert "session_id" in event.metadata
The deeper patterns — st.builds, type registration, recursive strategies — are covered in generating custom strategies with hypothesis.strategies. When one drawn field must determine the strategy for the next, reach for .flatmap and @st.composite together, detailed in composing strategies with flatmap and composite.
Step 3 — Integrate with pytest fixtures and assume()
Unlike parametrized tests where fixtures run once per function, @given executes the body multiple times, so fixtures are injected per generated example. Scope expensive resources accordingly, and use assume() for rare preconditions.
import pytest
from hypothesis import given, settings, assume
import hypothesis.strategies as st
from sqlalchemy import create_engine, Column, Integer, String
from sqlalchemy.orm import Session, declarative_base
Base = declarative_base()
class Record(Base):
__tablename__ = "records"
id = Column(Integer, primary_key=True)
payload = Column(String, nullable=False)
@pytest.fixture(scope="function")
def db_session(tmp_path):
engine = create_engine(f"sqlite:///{tmp_path}/test.db")
Base.metadata.create_all(engine)
with Session(engine) as session:
yield session # fresh DB per Hypothesis example
@given(st.text(min_size=1, max_size=100))
@settings(max_examples=50)
def test_insert_round_trip(db_session: Session, payload: str) -> None:
assume("\x00" not in payload) # SQLite rejects null bytes — rare, so assume() fits
db_session.add(Record(payload=payload)); db_session.commit()
fetched = db_session.query(Record).filter_by(payload=payload).first()
assert fetched is not None and fetched.payload == payload
Prefer assume() for rare or cross-field constraints; prefer .filter() for common, easily satisfiable ones. Overusing assume() starves the generator and eventually trips the filter_too_much health check, which aborts the test with Unsatisfiable — the exact failure mode walked through in fixing Hypothesis flaky health check failures.
Step 4 — Tune settings and the example database
Production suites need predictable execution. hypothesis.settings controls volume, deadlines, and database behavior.
from hypothesis import settings, given, Verbosity
from hypothesis.database import DirectoryBasedExampleDatabase
import hypothesis.strategies as st
@settings(
max_examples=500,
deadline=500, # ms per example; raises DeadlineExceeded if breached
verbosity=Verbosity.normal,
database=DirectoryBasedExampleDatabase(".hypothesis/ci_cache"),
)
@given(st.dictionaries(st.text(), st.integers()))
def test_dict_dedup(data: dict[str, int]) -> None:
assert len(data) == len({k: v for k, v in data.items()})
Increase max_examples for pure functions, decrease for I/O-heavy tests. The default deadline is 200ms; override per-test for genuinely slow operations rather than globally. Detailed tactics live in reducing Hypothesis test execution time.
Step 5 — Pin seeds for deterministic reproduction
@seed() fixes the generation sequence so a flaky failure replays identically across machines.
from hypothesis import given, seed
import hypothesis.strategies as st
@seed(12345) # identical generation everywhere — use while debugging, then remove
@given(st.lists(st.integers()))
def test_sort_is_stable_under_reverse(data: list[int]) -> None:
assert sorted(data) == sorted(data, reverse=True)[::-1]
Verification
[101] remains.- Run
pytest --hypothesis-show-statisticsand confirm each property reports the expected example count, a low rejection rate, and a saneGenerate/Shrinkratio. - Negate an assertion to force a failure; confirm Hypothesis reports a minimal counterexample (a short string, a small list) rather than the raw random input — this proves shrinking is active.
- Delete
.hypothesis/and re-run a failing test, then re-run again; the second run should replay the stored minimal example first, demonstrating the database works. - Re-run with
--hypothesis-seed=0twice and confirm identical generation, proving determinism for CI.
Troubleshooting
| Symptom | Root cause | Fix |
|---|---|---|
UnsatisfiedAssumptionError | Over-restrictive assume() rejecting too many examples | Move the constraint into the strategy with bounds (min_size/max_value) or st.sampled_from() |
DeadlineExceeded | Heavy I/O or complex strategy trees exceed the 200ms default | Set @settings(deadline=None) for genuinely slow tests and isolate them; bound recursion |
| Flaky failures across machines | Uncached database or non-deterministic seed | Cache .hypothesis/examples/; pin @seed() while debugging |
| Strategy explosion / memory bloat | Unbounded recursion or large st.sampled_from() collections | Add max_size/max_leaves, use st.deferred() for recursion |
| Fixtures behaving unexpectedly | Fixture runs per example, not per test | Use scope="function" and keep per-example resources cheap |
From an example-based suite to a property-based one
Adopting Hypothesis in an existing codebase works best as a conversion of tests you already have, not as a greenfield exercise. The path below takes a typical example-based test to a property in four moves, and each move is independently valuable.
Move one: parametrize what is already there. Collect the hard-coded examples scattered across several test functions into one @pytest.mark.parametrize list. This changes nothing about coverage, but it isolates the input from the assertion, which is the precondition for everything that follows.
import pytest
from myapp.money import parse_amount
@pytest.mark.parametrize("text,expected", [
("1.00", 100), ("0.01", 1), ("1234.56", 123456),
])
def test_parse_amount(text, expected):
assert parse_amount(text) == expected
Move two: replace the expected value with a property. The list above pairs each input with a hand-computed output, which is exactly what blocks generated input. Find the statement that is true of every valid input — here, that formatting the parsed value returns the original text — and assert that instead.
from hypothesis import given, strategies as st
from myapp.money import parse_amount, format_amount
@given(st.integers(min_value=0, max_value=10 ** 9))
def test_parse_format_round_trip(cents):
assert parse_amount(format_amount(cents)) == cents
Move three: keep the old examples as regression pins. Do not delete them. @example re-runs a specific input on every execution, before any generated data, so the historical bugs those examples encoded stay pinned. This is the step that makes the conversion safe to review: nothing that used to be covered stops being covered.
from hypothesis import example, given, strategies as st
@given(st.integers(min_value=0, max_value=10 ** 9))
@example(0) # the empty-amount bug from 2024
@example(999_999_999) # the overflow that shipped once
def test_parse_format_round_trip(cents):
assert parse_amount(format_amount(cents)) == cents
Move four: widen the strategy until it fails, then decide. Generated tests find their value at the boundaries, so push the domain outward — negative amounts, values beyond the currency's precision, text with unicode digits — and see what breaks. Every failure is a decision: either the code should handle it, or the strategy should exclude it and the exclusion documents a real precondition. Both outcomes are progress; a strategy that never fails is a strategy that is too narrow to be earning its runtime.
What to expect in the first week
Two things surprise teams new to this. The first is that Hypothesis will find bugs in test helpers before it finds them in production code: fixtures that assume ASCII, builders that assume non-empty lists, comparison functions that assume no None. Fix them; they are real, and they were hiding behind a curated example list.
The second is the deadline. Hypothesis fails a test whose individual example exceeds deadline (200ms by default), which catches genuinely slow code but also fires on the first example when an import or a lazy connection happens inside the test. Warm that work up in a fixture rather than raising the deadline, and the signal stays useful.
Finally, run the converted tests against the previous release before merging. A property that fails on the old code and passes on the new one has documented a real fix; a property that fails on both has found something older, and knowing which of the two you are looking at saves an afternoon of git bisect.
Reading a Hypothesis failure report
The report Hypothesis prints on failure contains four distinct pieces of information, and knowing what each is for turns a wall of output into a two-minute diagnosis.
The first is the falsifying example — the shrunk input, printed as the arguments the test was called with. This is the minimal case Hypothesis could find that still fails, so a large or complicated one is itself a signal: it usually means shrinking was blocked by a filter or by a strategy that rejects simpler candidates.
The second is the assertion or traceback, which is the ordinary pytest failure for that one example. Read it exactly as you would any other failure; the fact that the input was generated changes nothing about the diagnosis.
The third is the reproduction blob, printed when print_blob=True. Pasting the @reproduce_failure(...) decorator onto the test replays that precise example without regenerating, which is the fastest possible loop while fixing the bug. Remove it once the fix lands — it pins a serialised internal representation that will not survive a Hypothesis upgrade.
The fourth is the statistics, available with --hypothesis-show-statistics. They report how many examples were generated, how many were rejected by filters, how long generation took relative to the test body, and how many shrink attempts ran. A run showing thousands of rejected draws explains a slow test more clearly than any profiler will.
$ pytest tests/test_money.py --hypothesis-show-statistics -q
- during generate phase (0.42 seconds):
- Typical runtimes: < 1ms, of which < 1ms in data generation
- 100 passing examples, 0 failing examples, 87 invalid examples
Eighty-seven invalid examples out of one hundred and eighty-seven draws is the number to act on: the strategy is rejecting nearly half of what it generates, and that rejection is both the runtime cost and the reason a future counterexample may shrink badly.
One habit is worth adopting from day one. When a property fails, add the falsifying example as a permanent @example before fixing the code. The failing input then becomes a regression pin that runs on every future execution, in a form that survives Hypothesis upgrades and does not depend on the example database being present — which matters because CI caches are cleared far more often than anyone expects.
Where Hypothesis does not belong
Two categories of test are worse with generated input, and knowing them prevents the backlash that follows an over-enthusiastic rollout.
The first is anything whose correctness is a specific business rule rather than a general property. A VAT rate table, a pricing tier boundary, a regulatory rounding mode — these have exactly the values the specification says and nothing else. A generated test over them either reimplements the table in the assertion (proving nothing) or asserts something so weak it cannot fail. Write them as parametrized examples taken from the specification document, and link the document in a comment.
The second is anything with a slow, irreducible per-example cost: a test that provisions a container, calls a paid third-party API, or trains a model. A hundred examples of a two-second operation is a three-minute test, and lowering the example count until it fits removes the search that justified the approach. Test those paths with a handful of chosen examples and put the property on the pure logic underneath, where examples are cheap.
Between those two extremes sits nearly everything else — parsers, encoders, validators, calculations, data structures, state machines — and that is where the technique earns its runtime.
Frequently Asked Questions
How does Hypothesis differ from pytest's @pytest.mark.parametrize?
Parametrize runs a fixed, hand-written list of inputs. Hypothesis generates many boundary-pushing inputs per run and automatically shrinks any failure to a minimal reproducible case, eliminating manual edge-case enumeration.
What is the shrinking process and why is it critical? Shrinking is a delta-debugging pass that reduces a failing input to its simplest form while preserving the failure. It turns a 10,000-character random string into the few-character minimal reproducer, so debugging starts from the smallest possible case.
Can Hypothesis test async or await functions?
Yes, via pytest-asyncio or anyio integration, though event-loop management requires explicit fixture scoping and deadline tuning to absorb asynchronous scheduling overhead.
How do I persist and share failing examples across CI environments?
Hypothesis stores minimal failing examples in a local .hypothesis/examples/ database by default. Configure database=DirectoryBasedExampleDatabase(path) and cache or commit the directory so failures replay across runners.
When should I use assume() versus strategy filtering?
Use assume() for rare preconditions or cross-field dependencies evaluated inside the test. Use .filter() for common, easily satisfiable constraints at the strategy level. Overusing assume() starves the generator and trips the filter-ratio health check.
Related guides
- Once primitives feel natural, build domain generators with generating custom strategies with hypothesis.strategies.
- When one generated field constrains the next, chain strategies using flatmap and composite.
- Diagnose and silence spurious
Unsatisfiable/too_slowaborts with fixing Hypothesis flaky health check failures. - Keep CI fast with reducing Hypothesis test execution time.
- Graduate from single-call properties to sequences of operations with stateful and model-based testing, then survey the full toolkit in advanced property-based testing.
- Reproducing Hypothesis failures with @example — turn a random counterexample into a permanent regression test.
← Back to Property-Based & Fuzz Testing Strategies