# 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

```console
$ 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

```python
# Before
from freezegun import freeze_time


@freeze_time("2026-01-01 12:00:00")
def test_new_year():
    assert greeting() == "Happy new year"
```

```python
# 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

```python
# 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`:

```python
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:

```console
$ 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

- [time-machine: migrating from freezegun](https://time-machine.readthedocs.io/en/latest/migration.html)
- [time-machine: usage](https://time-machine.readthedocs.io/en/latest/usage.html) and [pytest plugin](https://time-machine.readthedocs.io/en/latest/pytest_plugin.html)
- [time-machine: comparison with freezegun and libfaketime](https://time-machine.readthedocs.io/en/latest/comparison.html)
- [Adam Johnson: freezegun versus time-machine](https://adamj.eu/tech/2021/02/19/freezegun-versus-time-machine/)
