Isolation & Contracts

Freezing Time: freezegun vs monkeypatch

You monkeypatch datetime.now to a fixed value, the test passes for the function you targeted, and then a helper in another module — or a C-extension serializer, or a time.time() call buried in a retry loop — reads the real clock and the test flakes anyway. The question is when a surgical monkeypatch.setattr is enough and when you need freezegun to replace the clock globally. This guide draws the line: monkeypatch controls exactly one name and nothing it cannot see, while freezegun swaps the datetime class across every module, and neither one stops C code that calls the libc clock directly.

Prerequisites

Solution

Python
from datetime import datetime, timezone
import pytest
from freezegun import freeze_time

# --- code under test, in module myapp.billing ---
# def invoice_stamp() -> str:
#     return datetime.now(timezone.utc).isoformat()

# Approach A — monkeypatch a single, known call site.
def test_with_monkeypatch(monkeypatch):
    fixed = datetime(2026, 6, 18, 12, 0, tzinfo=timezone.utc)

    class FrozenDatetime(datetime):
        @classmethod
        def now(cls, tz=None):            # only this classmethod is overridden
            return fixed

    # Patch the NAME as myapp.billing looks it up, not datetime globally.
    monkeypatch.setattr("myapp.billing.datetime", FrozenDatetime)
    from myapp.billing import invoice_stamp
    assert invoice_stamp() == "2026-06-18T12:00:00+00:00"

# Approach B — freezegun freezes datetime everywhere at once.
@freeze_time("2026-06-18T12:00:00Z")
def test_with_freezegun():
    from myapp.billing import invoice_stamp
    # No per-module patching: every module's datetime.now sees the frozen instant.
    assert invoice_stamp() == "2026-06-18T12:00:00+00:00"
    # time.time() is frozen too.
    import time
    assert time.time() == 1781870400.0

# Advancing a frozen clock with tick() and jumping with move_to().
def test_tick_advances_clock():
    with freeze_time("2026-06-18T12:00:00Z") as frozen:
        t0 = datetime.now(timezone.utc)
        frozen.tick()                     # +1 second (default)
        frozen.tick(delta=59)             # +59 seconds (int seconds or a timedelta)
        elapsed = (datetime.now(timezone.utc) - t0).total_seconds()
        assert elapsed == 60
        frozen.move_to("2027-01-01T00:00:00Z")   # jump to an absolute instant
        assert datetime.now(timezone.utc).year == 2027

The decision rule in one line: count the clock reads in the code path. One local name reachable from the test module → monkeypatch. More than one, or time.time, or a transitive call you do not own → freeze_time. Use tick(delta=...) for relative elapsed-time assertions and move_to(...) when the test needs the clock at a specific wall-clock instant (a billing boundary, a token-expiry timestamp, a DST changeover).

The two tools differ in blast radius rather than in accuracy, and that difference is what should drive the choice.

What each approach actually intercepts A stacked comparison of four clock surfaces — the module-level name you rebind, other modules importing the same clock, the datetime and date classes, and C-level clocks such as time.monotonic used by libraries — showing that monkeypatch reaches only the first while freezegun reaches the first three and deliberately leaves monotonic clocks alone. What each approach actually intercepts the name you rebind monkeypatch: yes · freezegun: yes same clock in other modules monkeypatch: no · freezegun: yes datetime.now / date.today monkeypatch: only if patched too · freezegun: yes time.monotonic, C extensions monkeypatch: no · freezegun: no by default freezegun ships tick() so a frozen clock can still advance deterministically.
Reach is the whole decision: a single rebound name is precise and cheap, a global freeze is thorough and slow.

Why this works

monkeypatch.setattr("myapp.billing.datetime", ...) rebinds a single attribute in a single module's namespace and reverts it on teardown; it is precise and dependency-free, but it is blind to any other module that imported its own datetime reference and to time.time(). The mechanism is exactly the name-binding rule covered in where to patch: understanding mock.patch targets — you must target the lookup namespace, not the definition. freezegun instead walks sys.modules on entry and replaces references to the real datetime, date, and time symbols with FakeDatetime/FakeDate objects everywhere they are bound, and it patches time.time, time.monotonic, and time.localtime, so transitive calls across modules all observe the same frozen instant. The freeze_time controller exposes tick() because a frozen clock is static by default; tick(delta=...) mutates the stored instant so you can assert elapsed-time behaviour without sleeping.

How far each tool reaches to intercept a clock read Three nested regions of coverage. monkeypatch is the innermost region: it reaches only one module's datetime name. freezegun encloses it and reaches every Python-level datetime and time reference across sys.modules. time-machine encloses both and is the only region that also reaches CPython C-level clock reads. A datetime.now call in the patched module is caught by all three; a call in another module or a time.time call is caught by freezegun and time-machine but missed by monkeypatch; a C-extension libc clock read is caught only by time-machine. How far each tool reaches to intercept a clock read time-machine — CPython C-level clock freezegun — all sys.modules datetime/time monkeypatch — one module namespace datetime.now() in myapp.billing reached by all three tools datetime.now() elsewhere · time.time() freezegun & time-machine only — monkeypatch misses C-extension → libc clock read only time-machine intercepts it
How far each tool reaches: monkeypatch rebinds one name in one module, while freezegun intercepts every Python-level clock read for the duration of the block.

The trade-off, then, is reach versus cost. monkeypatch is a scalpel that touches one binding and adds no dependency; freezegun is a net cast over every Python-level clock read at the cost of a slower entry (it imports and scans sys.modules) and a third-party dependency. Neither reaches below the Python layer.

Edge cases and failure modes

  • C-extension clock reads defeat both. Code in a compiled extension (or some serializers) that calls the libc clock directly never goes through Python's datetime module, so neither monkeypatch nor freezegun touches it. Switch to time-machine, which patches at the CPython clock level — see Controlling Time and Randomness in Tests.
  • from datetime import datetime in the target. monkeypatch must target the consuming module's datetime name (myapp.billing.datetime), not datetime.datetime. Targeting the wrong name is the same namespace trap covered in patching strategies for complex codebases.
  • freezegun and naive datetimes. Freezing to a Z/UTC string still returns a naive datetime.now() unless you call now(timezone.utc). Mixing naive and aware datetimes raises TypeError on comparison; freeze and read consistently.
  • tick=True drift. With freeze_time(..., tick=True) the clock advances with real wall time, reintroducing nondeterminism for sub-second assertions. Keep the default static freeze and advance explicitly with tick(delta=...).
  • .start()/.stop() leakage. Calling freeze_time(...).start() without a matching stop() leaks the frozen clock into later tests. Prefer the decorator or with form so teardown is guaranteed, as with any unittest.mock patch lifecycle.
  • Modules that must keep real time. freezegun freezes globally, which breaks libraries that legitimately need the real clock inside the frozen block (schedulers, some loggers). Pass freeze_time(..., ignore=["threading", "mylib.scheduler"]) to exclude module prefixes from the patch rather than dropping the freeze entirely.
  • Injected clocks sidestep the whole question. If the code under test takes its clock as a now: Callable[[], datetime] parameter or constructor argument, you pass a stub lambda and neither tool is needed — the same argument for injecting fakes vs mocks in constructors. Prefer this for new code; reach for freezegun when you cannot change the signature.

The cost side of the decision

Reach is only half the trade-off; the other half is what each approach does to suite runtime and to the failures you get when it goes wrong.

monkeypatch.setattr costs one attribute write and one restore per test — microseconds, and no import-time work at all. freezegun installs replacement classes for datetime.datetime and datetime.date, then walks sys.modules to rebind every module-level reference it can find to the real ones. On a suite with a few hundred imported modules, that sweep is measurable: a per-test freeze_time typically adds single-digit milliseconds, which is invisible on fifty tests and very visible on five thousand.

The failure modes differ too. A monkeypatched clock that missed a module produces a test asserting on a real timestamp — usually an off-by-microseconds comparison failure that points straight at the unpatched import. A frozen clock that leaks past its scope produces something worse: an unrelated test elsewhere in the session sees a fixed datetime.now(), and the failure lands nowhere near the cause. Keep freeze_time inside a context manager or a decorator rather than starting it in a fixture without a matching stop.

Python
import datetime as dt
from freezegun import freeze_time

FIXED = dt.datetime(2026, 3, 1, 12, 0, 0, tzinfo=dt.timezone.utc)

def test_scoped_freeze():
    with freeze_time(FIXED) as frozen:      # scope ends with the block, always
        assert dt.datetime.now(dt.timezone.utc) == FIXED
        frozen.tick(delta=dt.timedelta(seconds=30))   # advance deterministically
        assert dt.datetime.now(dt.timezone.utc) == FIXED + dt.timedelta(seconds=30)
    assert dt.datetime.now(dt.timezone.utc) > FIXED   # real clock restored

Two rules keep the choice out of review debates. Freeze globally only when the code under test reads the clock through paths you do not control — an ORM's default=datetime.utcnow, a third-party retry helper, a serializer that stamps records. Rebind a single name whenever the clock is injected or read from one module you own, and pair it with a now() seam in production code so the test never has to reach for a global at all.

One more distinction worth stating: neither tool patches time.monotonic. Deadline and timeout logic built on the monotonic clock is unaffected by both, which is a feature — timeouts should be measured against a clock that cannot be moved backwards. Test that logic by injecting a fake monotonic source instead.

Plotted against the two costs that actually matter — how much of the process the freeze touches, and what it adds per test — the choice stops being a matter of taste.

Per-test overhead of each clock strategy A bar chart of approximate per-test overhead: injecting a clock seam costs essentially nothing, monkeypatching one name costs microseconds, a scoped freeze_time block costs a few milliseconds, and a session-wide freeze applied per test costs the most. Per-test overhead of each clock strategy injected clock seam ~0 ms monkeypatch one name <0.1 ms scoped freeze_time ~2-4 ms freeze at fixture scope ~5-7 ms Indicative figures — measure your own suite before optimising.
Overhead measured on a suite with roughly 300 imported modules; the freeze cost scales with sys.modules, not with test size.

Frequently Asked Questions

Why doesn't monkeypatching datetime.now work for some code?datetime.datetime is a C type, so you cannot set an attribute on it, and code that imported now or called datetime.now() in another module still resolves the original. monkeypatch.setattr only fixes the one name you target, while freezegun replaces the datetime class everywhere it is referenced.

How does freezegun's tick() advance a frozen clock?freeze_time returns a controller whose tick() method advances the frozen instant by a timedelta, one second by default. Pass delta to advance further. By default the frozen time does not move on its own; tick=True makes it advance with real elapsed time.

Is monkeypatch ever the right choice over freezegun for time? Yes, when only one well-known call site reads the clock and you want zero extra dependencies. monkeypatch.setattr on that module's now reference is faster and explicit, but it does not cover transitive calls, time.time, or C-extension clocks the way freezegun does.

← Back to Controlling Time and Randomness in Tests