Hypothesis & Fuzzing

Constraining Dates and Timezones in Strategies

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.100 and Python 3.9+ for zoneinfo.
  • The system's tzdata available — on minimal containers install the tzdata package.
  • The composition techniques in designing strategies for domain data.

Solution

Python
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
Where calendar bugs hide A timeline of calendar boundaries: month ends, the leap day, a spring-forward gap where a local hour does not exist, an autumn fold where a local hour repeats, and a year end. A mid-month noon example touches none of them; a bounded timezone-aware strategy biased toward boundaries reaches all of them. The interesting dates are the boundaries 31 Jan 29 Feb DST gap 01:30 does not exist 15 Jun 12:00 the usual example DST fold 01:30 happens twice 31 Dec Hand-written examples cluster in the green; the bugs cluster in the coral and gold.
A strategy biased toward the coloured points reaches every boundary within a few hundred examples.

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=False by 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. Install tzdata in 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.

Four properties for calendar code Four cards. Monotonicity checks that next or later operations never move backwards. Coverage checks that split periods are contiguous and cover the whole range. Idempotence checks that normalising twice equals once. Round trips check that storing and loading preserves the instant. Four properties catch most calendar bugs monotonicity next_billing_date(d) > d, always coverage periods contiguous, ends match the range idempotence start_of_month(start_of_month(d)) == start_of_month(d) round trip load(store(t)) is the same instant
Each is one line of assertion; the strategy supplies the hundreds of boundary cases it is checked against.

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.

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

Round-trip property for stored times A generated aware moment is converted to UTC and serialised for storage, then read back and converted to the original zone. The result must be the same instant. Typical failures are offsets truncated to whole hours, microseconds dropped, and the fold flag lost during a daylight-saving repeat. Out to storage and back: the same instant moment Asia/Kathmandu to UTC, store read, convert back == moment? typical shrunk failures +05:45 truncated to +05:00 · microseconds dropped · fold lost in the repeated hour
Each failure shrinks to one moment in one zone — usually a zone with a non-hour offset, which hand-written tests almost never use.

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.

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

← Back to Designing Strategies for Domain Data