Speed up your test suite

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=False wherever a test expects a frozen clock.
  • Naive datetimes: time-machine treats a naive datetime or date destination as UTC, where freezegun used local time. Pass timezone-aware datetimes (tzinfo=dt.UTC, or a ZoneInfo, 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() or time.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 advancing monotonic(), inject a clock or make the timeout configurable.
  • freezegun options without an equivalent: tz_offset and auto_tick_seconds. ignore isn'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

Related guides

Updated October 18, 2026 · Suggest a change