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
freezegun >= 1.5andpytest >= 8.0.- Python
3.9+. - For the C-extension gotcha at the end,
time-machine >= 2.14is the escape hatch, as introduced in Controlling Time and Randomness in Tests.
Solution
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().
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
elapsed = (datetime.now(timezone.utc) - t0).total_seconds()
assert elapsed == 60
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.
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(). freezegun instead walks sys.modules and replaces references to the real datetime class with a FakeDatetime everywhere, and it patches time.time, 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.
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
datetimemodule, so neithermonkeypatchnorfreezeguntouches it. Switch totime-machine, which patches at the CPython clock level — see Controlling Time and Randomness in Tests. from datetime import datetimein the target.monkeypatchmust target the consuming module'sdatetimename (myapp.billing.datetime), notdatetime.datetime. Targeting the wrong name is the same namespace trap covered in patching strategies for complex codebases.freezegunand naive datetimes. Freezing to aZ/UTC string still returns a naivedatetime.now()unless you callnow(timezone.utc). Mixing naive and aware datetimes raisesTypeErroron comparison; freeze and read consistently.tick=Truedrift. Withfreeze_time(..., tick=True)the clock advances with real wall time, reintroducing nondeterminism for sub-second assertions. Keep the default static freeze and advance explicitly withtick(delta=...)..start()/.stop()leakage. Callingfreeze_time(...).start()without a matchingstop()leaks the frozen clock into later tests. Prefer the decorator orwithform so teardown is guaranteed, as with any unittest.mock patch lifecycle.
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