Most date bugs live at boundaries: the last day of a month, a leap day, the hour that does not exist when clocks spring forward, the hour that happens twice when they fall back, a timezone whose offset changed in a year the code did not expect. A test that uses datetime(2026, 6, 15, 12, 0) exercises none of them. A property test with a well-constrained datetime strategy exercises all of them within a few hundred examples, and shrinks any failure to the single boundary that caused it.
The constraints are what make it work. An unconstrained strategy generates dates in the year 1 and times in timezones the application never serves, producing failures about database column limits rather than calendar logic. Bounding the range, choosing real timezones, constructing ordered ranges rather than filtering them, and deliberately biasing toward the boundaries turns the strategy into a calendar-bug finder.
Prerequisites
hypothesis >= 6.100and Python 3.9+ forzoneinfo.- The system's tzdata available — on minimal containers install the
tzdatapackage. - The composition techniques in designing strategies for domain data.
Solution
import datetime as dt
from hypothesis import given, strategies as st
MIN = dt.datetime(2020, 1, 1)
MAX = dt.datetime(2035, 12, 31)
# Aware datetimes in real IANA zones, including DST gaps and folds.
moments = st.datetimes(min_value=MIN, max_value=MAX, timezones=st.timezones())
@st.composite
def date_ranges(draw, max_days=400):
start = draw(moments)
length = draw(st.timedeltas(min_value=dt.timedelta(0),
max_value=dt.timedelta(days=max_days)))
return start, start + length # ordered by construction
# Bias: a quarter of draws land on month ends, where billing bugs live.
month_ends = st.builds(
lambda y, m: (dt.date(y, m % 12 + 1, 1) if m < 12 else dt.date(y + 1, 1, 1))
- dt.timedelta(days=1),
st.integers(2020, 2035), st.integers(1, 12),
)
dates = st.one_of(month_ends, st.dates(min_value=MIN.date(), max_value=MAX.date()))
@given(date_ranges())
def test_billing_periods_cover_the_range_exactly(r):
start, end = r
periods = split_into_billing_periods(start, end)
assert periods[0].start == start and periods[-1].end == end
assert all(a.end == b.start for a, b in zip(periods, periods[1:])) # no gaps
@given(dates)
def test_next_billing_date_is_always_later(d):
assert next_billing_date(d) > d
Why this works
st.datetimes with timezones=st.timezones() draws a naive local time within the bounds and a real IANA zone, then attaches the zone. Because the local time is generated independently of the zone's rules, some draws land in a DST gap — a local time that never occurred — or a fold, where it occurred twice and the fold attribute decides which. Those are exactly the values that break naive arithmetic, and Hypothesis's shrinker reduces any failure to the simplest such case.
Constructing ranges from a start and a non-negative duration guarantees validity without discarding draws, and shrinks toward a zero-length range at the earliest permitted date — a minimal, obviously-boundary counterexample. Biasing with st.one_of changes the distribution without narrowing it: month ends appear often, ordinary dates still appear, and nothing valid is excluded. Without the bias, a uniform draw over fifteen years lands on a month end roughly one time in thirty, which means a property run with a hundred examples may test only three or four of them.
Edge cases and failure modes
- Unbounded ranges. Years near 1 or 9999 overflow when a timedelta is added, and fail on database columns. Bound to the system's real range.
allow_imaginary=Falseby reflex. Excluding non-existent local times hides the DST bugs the strategy exists to find. Only exclude them when the specification says those inputs are rejected upstream.- Missing tzdata. Slim containers lack the IANA database, so
st.timezones()fails or draws only UTC. Installtzdatain the test image. - Comparing aware and naive values. Python raises on the comparison. Generate one kind per property and convert explicitly.
- Assuming a day is 24 hours. Across a DST change a local day is 23 or 25 hours. Properties about "the next day" should use calendar arithmetic, not a fixed timedelta.
Properties worth stating about calendar code
Generating the right dates is half the job; the other half is knowing what to assert. Calendar code has a small set of properties that hold for almost any implementation and catch most of its bugs.
Monotonicity. "Next billing date" is always later than the input; "start of week" is never later than the input; adding a positive duration never moves backwards. These are cheap to state and fail on off-by-one and DST errors immediately.
Coverage without gaps or overlaps. Splitting a range into periods — days, billing cycles, reporting weeks — must produce contiguous pieces whose union is the whole range. The test above checks the ends and adjacency; together those rule out gaps, overlaps and lost time at DST transitions.
Idempotence. Normalising a date twice gives the same result as once; truncating to the start of a month twice is the same as once. Violations usually mean the operation depends on the local time of day, which matters exactly when a DST change moves midnight.
Round trips. Serialise and parse, convert out and back, store and load — the instant must survive, as the section above shows.
A calendar module with one test of each kind, driven by the constrained strategies here, is covered far better than one with dozens of hand-picked examples, because each property is checked against every boundary the strategy reaches rather than the handful an author thought to write down.
Round-tripping through UTC and storage
A particularly productive property for any system that stores times is the round trip: convert to UTC for storage, read it back, convert to the original zone, and compare. It sounds trivial and finds real bugs, because it passes through exactly the code paths — serialisation, database drivers, offset handling — where timezone information gets lost.
from hypothesis import given
@given(moments)
def test_stored_times_round_trip(moment):
stored = to_storage(moment) # e.g. ISO string in UTC
restored = from_storage(stored).astimezone(moment.tzinfo)
assert restored == moment # the same instant
The equality compares instants, not wall-clock representations, which is correct: a time stored at 01:30 during a fold must come back as the same instant, not merely the same local digits. The failures this finds are almost always one of three: an offset truncated to whole hours (breaking India, Nepal and parts of Australia), microseconds dropped by a serialiser, or the fold flag lost so that the second 01:30 comes back as the first. Each shrinks to a single moment in a single zone, which makes the cause immediately visible.
Pinning the boundaries that were found
When a property finds a calendar bug, the counterexample is precious: a specific instant in a specific zone that broke the code. The fix should come with that instant pinned as an explicit @example, so it runs on every future execution regardless of what the generator draws.
import datetime as dt
from zoneinfo import ZoneInfo
from hypothesis import example, given
@given(moments)
@example(dt.datetime(2026, 10, 25, 1, 30, fold=1, tzinfo=ZoneInfo("Europe/London")))
@example(dt.datetime(2026, 3, 29, 1, 30, tzinfo=ZoneInfo("Europe/London"))) # gap
def test_stored_times_round_trip(moment):
...
Two such examples — the repeated hour and the missing hour in the zone the business actually operates in — are worth adding to every timezone-sensitive property even before any bug is found. The generator will reach similar cases eventually, but these are the two instants most likely to occur in production, and guaranteeing they are checked on every run costs nothing. Over time the list of pinned examples becomes a compact record of the calendar edge cases the system has been bitten by, which is useful documentation in its own right.
Frequently Asked Questions
Should strategies generate naive or aware datetimes?
Whichever the code under test accepts, and if it accepts both, generate both in separate properties. Most application code should handle only aware datetimes, and a strategy that passes timezones=st.timezones() exercises exactly that, including zones with unusual offsets and historical changes.
How do I make sure DST edge cases are generated?
Use st.timezones() for real IANA zones and leave allow_imaginary at its default of True. Hypothesis then includes local times that fall in DST gaps and folds. If the code must reject non-existent times, keep them; if it cannot meaningfully handle them, set allow_imaginary=False deliberately and document why.
How do I generate a start and end date where end is after start? Generate the start, then generate a non-negative timedelta and add it. Filtering two independent dates for ordering discards half of every draw and shrinks poorly, while constructing the range guarantees validity and shrinks to a zero-length range at the earliest bound.
Related
- Designing Strategies for Domain Data — construction over filtering, applied generally.
- Injecting a Clock Instead of Patching datetime — feeding generated moments into a fake clock.
- Round-Trip Properties for Serializers and Parsers — the round-trip pattern in general.
- Why Hypothesis Shrinking Stalls and How to Fix It — why filtered date pairs shrink badly.
← Back to Designing Strategies for Domain Data