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().
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 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.

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