Replace freezegun with time-machine
freezegun scans every loaded module each time it freezes time, which gets slow in big projects. time-machine mocks time at the C level. How to migrate, including tick=False.
freezegun is the classic way to control time in Python tests: @freeze_time("2026-01-01") and datetime.now() returns that date. It works, but every time it starts (each decorated test, each with freeze_time(...) block) it does work proportional to the number of modules your process has imported. In a small project you don't notice. In a large Django project with thousands of loaded modules, starting and stopping freezegun can cost more than the test itself.
time-machine has nearly the same API and doesn't have this cost.
Why freezegun is slow
Python code gets the current time from functions like time.time and datetime.datetime.now, and modules often hold their own references to them (from time import time, from datetime import datetime). To replace all of them, freezegun walks every module in sys.modules, looks through its attributes for references to the real functions and classes, and swaps them for fakes. When the freeze ends, it puts them back.
That's a find-and-replace over your whole import graph, on every start. freezegun caches what it found per module, but it still lists every module's attributes (dir(module)) to check whether the cache is valid. The cost grows with the number of loaded modules, so it gets worse as the project and its dependencies grow, and it's paid by every frozen test.
time-machine takes a different route: it replaces the underlying C functions of the standard library (time.time, datetime.datetime.now and the others) directly. Every reference, wherever it was imported, now calls the mock, and nothing has to be scanned.
Migrate to time-machine
$ uv add --dev time-machine
time-machine needs CPython (3.11 or later in current versions), because it hooks into the C-level API.
The decorator and context manager
# Before
from freezegun import freeze_time
@freeze_time("2026-01-01 12:00:00")
def test_new_year():
assert greeting() == "Happy new year"
# After
import datetime as dt
import time_machine
@time_machine.travel(dt.datetime(2026, 1, 1, 12, 0, tzinfo=dt.UTC), tick=False)
def test_new_year():
assert greeting() == "Happy new year"
time_machine.travel(destination, tick=True) works as a decorator (on functions, async functions and unittest.TestCase classes) and as a context manager (with and async with). The destination can be a datetime, a date, a Unix timestamp, a timedelta relative to now, or a string.
tick: time keeps running by default
The biggest difference: freezegun freezes the clock, time-machine travels to the destination and lets the clock keep running. Two calls to datetime.now() a few microseconds apart return different values. Pass tick=False for freezegun's frozen behaviour. Tests that compare exact timestamps (assert obj.created_at == dt.datetime(2026, 1, 1, 12, 0)) need it.
Moving the clock during a test
# Before
with freeze_time("2026-01-01") as frozen:
frozen.tick(dt.timedelta(hours=1))
frozen.move_to("2026-02-01")
# After
with time_machine.travel(dt.datetime(2026, 1, 1, tzinfo=dt.UTC), tick=False) as traveller:
traveller.shift(dt.timedelta(hours=1))
traveller.move_to(dt.datetime(2026, 2, 1, tzinfo=dt.UTC))
shift() takes a timedelta or a number of seconds, and has no default, so freezegun's bare tick() becomes shift(1).
pytest: the time_machine fixture and marker
time-machine is also a pytest plugin. The time_machine fixture replaces pytest-freezer's freezer fixture, and the marker replaces pytest.mark.freeze_time:
import datetime as dt
import pytest
def test_subscription_expires(time_machine):
time_machine.move_to(dt.datetime(2026, 1, 1, tzinfo=dt.UTC), tick=False)
subscription = start_trial(days=14)
time_machine.shift(dt.timedelta(days=15))
assert subscription.is_expired()
@pytest.mark.time_machine(dt.datetime(2026, 1, 1, tzinfo=dt.UTC), tick=False)
def test_invoice_date():
assert create_invoice().issued_on == dt.date(2026, 1, 1)
Unlike freezegun's fixture, the time_machine fixture doesn't mock time until you call move_to(). The marker also covers function-scoped fixtures' setup and teardown.
The migration tool
time-machine ships a tool that rewrites freezegun usage for you, adding tick=False to keep the frozen behaviour:
$ git ls-files -z '*.py' | xargs -0 uvx --from 'time-machine[cli]' python -m time_machine migrate
It takes file paths, edits the files in place and reports the usages it couldn't migrate. Commit first, then review the diff and run your linter and tests: it can leave code that needs a manual fix.
Keep the tests correct
- time-machine travels by default (the clock keeps running); use
tick=Falsewherever a test expects a frozen clock. - Naive datetimes: time-machine treats a naive
datetimeordatedestination as UTC, where freezegun used local time. Pass timezone-aware datetimes (tzinfo=dt.UTC, or aZoneInfo, which also sets the current time zone) so the tests don't depend on the machine they run on. - time-machine doesn't mock
time.monotonic()ortime.perf_counter(); freezegun freezes them. Code that measures timeouts with the monotonic clock sees real time, which also means asyncio keeps working. If a test relied on freezegun advancingmonotonic(), inject a clock or make the timeout configurable. - freezegun options without an equivalent:
tz_offsetandauto_tick_seconds.ignoreisn't needed. - Mocking is process-wide: it affects all threads, but not subprocesses, and not clocks read outside the standard library (for example SQLite's or a database server's
now()).
How flakytest finds it
flakytest measures the time spent inside freezegun's code. When freezegun takes more than half a second and 1% of test time, it suggests Replace freezegun with time-machine, with the tests and lines that start freezegun the most. Since almost all of that time is the module scan, it estimates the saving at 90% of it. Run flakytest compare after the migration to check the saving and that no test changed its outcome.
Further reading
- time-machine: migrating from freezegun
- time-machine: usage and pytest plugin
- time-machine: comparison with freezegun and libfaketime
- Adam Johnson: freezegun versus time-machine
Related guides
- Tests are sleeping: replace time.sleep: Fixed time.sleep and asyncio.sleep calls make pytest suites slow and flaky. Poll for the condition, patch the sleep in retry and backoff code, and move the clock with time-machine.
- Expensive mock setup (unittest.mock, autospec): mock.patch with autospec=True, create_autospec and deep MagicMock chains cost real time when repeated in every test. Patch once per class or module and reuse spec'd mocks.
- Fix slow pytest startup and heavy imports in tests: pytest takes seconds before the first test runs? Find slow imports with python -X importtime, set testpaths, trim conftest.py imports and disable plugins you don't use.
Updated October 18, 2026 · Suggest a change