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() 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.
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.
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
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.- Modules that must keep real time.
freezegunfreezes globally, which breaks libraries that legitimately need the real clock inside the frozen block (schedulers, some loggers). Passfreeze_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 forfreezegunwhen 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.
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.
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.
Related guides
- When the frozen clock never reaches a compiled serializer, drop to the C-level patcher described in Controlling Time and Randomness in Tests.
- The single-name targeting rule behind the
monkeypatchapproach is spelled out in where to patch: understanding mock.patch targets. - To replace a clock via constructor injection instead of patching, see injecting fakes vs mocks in constructors.
- If the
FrozenDatetimesubclass grows real methods, lock its contract with autospec strict mocking. - When a frozen
now()feeds an awaited call, pick the right double with Mock vs MagicMock vs AsyncMock — when to use each.
← Back to Controlling Time and Randomness in Tests