Hypothesis & Fuzzing

Hypothesis Framework Fundamentals for Python Testing

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.100 and pytest>=8.0 installed in the active virtual environment.
  • Familiarity with pytest fixture lifecycles and basic decorators.
  • Optional: sqlalchemy for 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 random directly. 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.

Generate, shrink, and database lifecycle A strategy generates an example, the test runs, a failure triggers shrinking, and the minimal example is stored in the database and replayed first on the next run. The Hypothesis execution lifecycle Generate strategy draws input Execute run the property Shrink minimize the failure Database .hypothesis/examples fails next run: stored minimal example is replayed first regressions are caught immediately, not rediscovered
Each example flows generate, execute, and on failure shrink; the minimized input is stored in the example database and replayed first on the next run so regressions resurface instantly.

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).

Python
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.

Python
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.

Python
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.

Python
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.

Python
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

The shrinking engine reducing a failing list to a minimal counterexample A raw random failing list of nine integers is reduced across three passes: unneeded elements are dropped, then the surviving value is shrunk toward zero, ending at the single-element minimal counterexample [101]. Every candidate re-runs the property and only reductions that still fail are kept. Shrinking: raw failure to minimal counterexample Pass 0 raw failure [ 837 12 9 450 3 288 71 6 155 ] FAILS drop unneeded elements Pass 1 [ 450 288 155 ] FAILS drop unneeded elements Pass 2 [ 155 ] FAILS shrink value toward 0 (stops at the boundary that still breaks) Pass 3 minimal [ 101 ] MINIMAL Every candidate re-runs the property — only reductions that still reproduce the failure are kept.
Shrinking is a delta-debugging pass: Hypothesis drops elements and pulls values toward zero, re-checking the property after each reduction and keeping only candidates that still fail — until the single-element counterexample [101] remains.
  • Run pytest --hypothesis-show-statistics and confirm each property reports the expected example count, a low rejection rate, and a sane Generate/Shrink ratio.
  • 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=0 twice and confirm identical generation, proving determinism for CI.

Troubleshooting

SymptomRoot causeFix
UnsatisfiedAssumptionErrorOver-restrictive assume() rejecting too many examplesMove the constraint into the strategy with bounds (min_size/max_value) or st.sampled_from()
DeadlineExceededHeavy I/O or complex strategy trees exceed the 200ms defaultSet @settings(deadline=None) for genuinely slow tests and isolate them; bound recursion
Flaky failures across machinesUncached database or non-deterministic seedCache .hypothesis/examples/; pin @seed() while debugging
Strategy explosion / memory bloatUnbounded recursion or large st.sampled_from() collectionsAdd max_size/max_leaves, use st.deferred() for recursion
Fixtures behaving unexpectedlyFixture runs per example, not per testUse 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.

Python
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.

Python
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.

Python
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.

Converting one example-based test in four moves A timeline of the four conversion moves: gather the hard-coded examples into a parametrized list, replace the expected values with a property, pin the historical examples with the example decorator, and widen the strategy until it finds something new. Converting one example-based test in four moves move 1 parametrize separate input from assertion move 2 find the property true for every valid input move 3 pin old examples @example keeps the history move 4 widen the domain each failure is a decision
Each move stands alone, so the conversion can be reviewed and merged one step at a time rather than as a rewrite.

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.

Bash
$ 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.

← Back to Property-Based & Fuzz Testing Strategies