You have a domain object — a date range, a financial record, a config with cross-field rules — and the obvious approach, st.builds(Model).filter(is_valid), has turned your suite into a CI bottleneck: --hypothesis-show-statistics shows most generated examples being discarded, shrinking stalls, and runs occasionally Unsatisfiable. The fix is to stop generating arbitrary data and filtering it, and instead build valid-by-construction strategies whose every output already satisfies the invariants. This guide shows the @st.composite, st.builds, and type-registration patterns that get rejection rates below the 15% threshold where Hypothesis stays fast and shrinking stays deterministic.
Prerequisites
hypothesis>=6.100,pytest>=8.0, Python 3.10+.- Working knowledge of
@givenand built-in strategies — see the Hypothesis framework fundamentals for the basics.
Solution
The core technique is conditional routing: pick the categorical fields first, then draw dependent fields from bounds derived from those choices, so no invalid object is ever produced.
from datetime import date, timedelta
import hypothesis.strategies as st
from hypothesis import given, settings, assume, Phase, Verbosity
@st.composite
def valid_time_ranges(draw: st.DrawFn) -> dict[str, date | int]:
# Draw start from a bounded domain so CI runs stay deterministic
start = draw(st.dates(min_value=date(2020, 1, 1), max_value=date(2024, 12, 31)))
# Route end_date generation: a valid future date OR the same day — never < start
end = draw(st.one_of(
st.dates(min_value=start, max_value=start + timedelta(days=365)),
st.just(start),
))
assume(end >= start) # cheap edge guard only; routing already guarantees validity
return {"start_date": start, "end_date": end, "duration_days": (end - start).days}
@given(time_range=valid_time_ranges())
@settings(max_examples=200, phases=[Phase.generate, Phase.shrink],
verbosity=Verbosity.normal, database=None)
def test_time_range_invariants(time_range: dict[str, date | int]) -> None:
assert time_range["start_date"] <= time_range["end_date"] # holds by construction
assert time_range["duration_days"] >= 0
When the strategy for a dependent field — not merely its numeric bounds — depends on an earlier draw, chain generators with flatmap and nested composites rather than widening a single st.one_of; that keeps each branch valid and shrinkable.
For pure constructors whose fields are independent, st.builds is more declarative and resolves type hints automatically. Register the strategy so st.from_type() finds it everywhere:
from dataclasses import dataclass, field
from typing import Optional, Literal
import hypothesis.strategies as st
from hypothesis import given, settings, Phase
@dataclass
class UserConfig:
username: str
tier: Literal["free", "pro", "enterprise"]
max_requests: Optional[int] = None
metadata: dict[str, str] = field(default_factory=dict)
def user_config_strategy() -> st.SearchStrategy[UserConfig]:
return st.builds(
UserConfig,
username=st.text(min_size=3, max_size=20).filter(str.isalnum), # cheap, rarely rejects
tier=st.sampled_from(["free", "pro", "enterprise"]),
max_requests=st.one_of(st.none(), st.integers(min_value=100, max_value=10_000)),
metadata=st.dictionaries(st.text(min_size=1, max_size=15), st.text(max_size=50)),
)
st.register_type_strategy(UserConfig, user_config_strategy()) # st.from_type() now resolves it
@given(config=st.from_type(UserConfig))
@settings(max_examples=100, phases=[Phase.generate, Phase.shrink])
def test_user_config(config: UserConfig) -> None:
assert config.username.isalnum()
if config.max_requests is not None:
assert config.max_requests >= 100
To extract a minimal failing input without the @given runner, use hypothesis.find(strategy, predicate) — it applies the same shrinking machinery and returns the smallest input satisfying the predicate, ideal for isolating a known business-rule violation.
Every strategy is built from the same four operations, and knowing which one you need shortens the search considerably.
Why this works
Hypothesis's rejection sampler discards invalid examples and retries; once rejection exceeds ~15%, throughput collapses and shrinking becomes non-deterministic because the search tree is fragmented by discarded branches. Routing generation with st.one_of and st.sampled_from moves validation from after generation to during it, so the shrinking engine only ever explores valid inputs and can converge on a minimal counterexample in milliseconds. st.builds adds declarative type resolution for the independent-field case, while st.register_type_strategy makes that resolution automatic across the suite.
Edge cases and failure modes
.filter()still present on a hot path — even one filter with a low pass rate dominates runtime; replace it with a pre-computedst.sampled_from(valid_values)when the valid set is finite.- Circular type resolution —
AreferencesBreferencesAcauses infinite generation; break it withst.recursive(..., max_leaves=...)orst.just()placeholders, and bound depth explicitly in recursive composites. - Unhashable types in
st.dictionaries/st.sets— generated mutable values raiseTypeError; convert totuple/frozensetbefore they are used as keys. - Mutable state leakage — if the test mutates a generated object,
copy.deepcopy()it first, or a cached example can carry mutations into the next run. - Global registration in a monorepo —
register_type_strategyis process-global; prefer local registration in the test module (or a fixture that registers and unregisters) to avoid polluting other suites.
Registering strategies for your own types
Once a project has more than a handful of custom strategies, passing them explicitly to every test becomes the dominant cost. hypothesis.strategies.register_type_strategy removes it: register a type once, and st.builds and st.from_type can construct anything that references it.
import uuid
from dataclasses import dataclass
from hypothesis import given, strategies as st
@dataclass(frozen=True)
class AccountId:
value: uuid.UUID
@dataclass(frozen=True)
class Order:
account: AccountId
total_cents: int
currency: str
# One registration teaches Hypothesis how to build every AccountId it ever needs.
st.register_type_strategy(AccountId, st.uuids().map(AccountId))
@given(st.from_type(Order)) # Order is built from its annotations
def test_total_is_non_negative(order: Order):
assert order.total_cents >= -2 ** 63
from_type reads the dataclass annotations, resolves each one — using the registry first, then its built-in mapping for standard types — and composes the result. The registration is global to the process, so put it in conftest.py rather than in a test module, and register the narrow types (AccountId, CurrencyCode) rather than the aggregates; aggregates are then derived automatically and stay correct when a field is added.
Two constraints are worth knowing before you lean on this. The registry keys on the exact type, so a subclass does not inherit its parent's registration — register each concrete type you generate. And a registration that produces invalid values is worse than none at all, because it silently affects every test that touches the type: a CurrencyCode strategy that emits arbitrary three-letter strings will generate "ZZZ", and any code path validating against a real currency list starts failing for reasons unrelated to the property under test. Constrain the registration to the domain:
CURRENCIES = ("EUR", "GBP", "USD", "JPY")
st.register_type_strategy(str, st.text()) # do NOT do this: too broad
st.register_type_strategy(CurrencyCode, st.sampled_from(CURRENCIES).map(CurrencyCode))
The second line is the pattern: a NewType or a small wrapper class gives you somewhere to attach the constraint, and sampled_from keeps the generated set inside the domain while still exercising every member.
For types you do not own, st.builds is the lighter-weight alternative — it takes the callable and infers the rest from annotations, letting you override individual arguments where the default inference is wrong:
@given(st.builds(Order, total_cents=st.integers(min_value=0, max_value=10 ** 9)))
def test_positive_orders_round_trip(order: Order):
assert Order(**{**order.__dict__}) == order
That override is also the escape hatch when a registered strategy is too broad for one specific test: keep the global registration honest and narrow it locally where a property needs a tighter domain.
A final note on debugging a strategy that misbehaves: strategy.example() draws a single value outside a test, which is the fastest way to see what a composite actually produces. It is deliberately unsuitable for use inside tests — it ignores the database, cannot shrink, and Hypothesis warns when it is called from a test function — but at a REPL it turns a strategy that is silently generating the wrong shape into a two-second check. Pair it with st.integers().map(str).validate(), which raises immediately when a strategy is malformed rather than at first draw.
Frequently Asked Questions
Why are my custom strategies so slow?
Almost always because .filter() is discarding most generated examples and retrying. Replace it with @st.composite conditional routing or a pre-filtered st.sampled_from so every draw is valid by construction, and verify the rejection rate stays under 15% with --hypothesis-show-statistics.
When should I use st.builds versus @st.composite?
Use st.builds for pure constructors whose fields are independent — it maps keyword strategies to arguments and resolves type hints automatically. Use @st.composite when fields are correlated, such as end_date >= start_date, because builds cannot enforce cross-field constraints.
How do I register a strategy so st.from_type() finds it automatically?
Call st.register_type_strategy(YourType, your_strategy()). Hypothesis walks the MRO and checks registered strategies before built-in inference. Prefer local registration in test modules to avoid polluting the global cache in large repositories.
Related
- Composing strategies with flatmap and composite — chain strategies when a field's generator, not just its bounds, depends on an earlier draw.
- Reducing Hypothesis test execution time — profiling and phase tuning for when custom strategies slow CI.
- Fixing Hypothesis flaky health-check failures — diagnose the
FailedHealthChecka high rejection rate triggers. - Modeling REST APIs as state machines — reuse these valid-by-construction strategies as
RuleBasedStateMachineinputs. - Hypothesis framework fundamentals — the underlying generation and shrinking model these strategies build on.
← Back to Advanced Property-Based Testing