Migrating from freezegun or libfaketime

freezegun has a useful API, and python-libfaketime copies some of it, with a different function name. time-machine also copies some of freezegun’s API, in travel()'s destination, and tick arguments, and the shift() method. There are a few differences:

  • time-machine’s tick argument defaults to True, because code tends to make the (reasonable) assumption that time progresses whilst running, and should normally be tested as such. Testing with time frozen can make it easy to write exact assertions, but it’s quite artificial. Write assertions against time ranges, rather than against exact values.

  • freezegun interprets dates and naive datetimes in the local time zone (including those parsed from strings with dateutil). This means tests can pass when run in one time zone and fail in another. time-machine instead interprets dates and naive datetimes in UTC so they are fixed points in time. Provide time zones where required.

  • freezegun’s tick() method has been implemented as shift(), to avoid confusion with the tick argument. It also requires an explicit delta rather than defaulting to 1 second.

  • freezegun’s tz_offset argument is not supported, since it only partially mocks the current time zone. Time zones are more complicated than a single offset from UTC, and freezegun only uses the offset in time.localtime(). Instead, time-machine will mock the current time zone if you give it a datetime with a ZoneInfo timezone.

Some features aren’t supported like the auto_tick_seconds argument. These may be added in a future release.

If you are only fairly simple function calls, you should be able to migrate by replacing calls to freezegun.freeze_time() and libfaketime.fake_time() with time_machine.travel().

Migration CLI

time-machine comes with a command-line interface to help you migrate from freezegun. It performs partial replacements on your code to update it to use time-machine’s API. It may leave your code in a broken state, for example where an import of freezegun has been replaced but calls using it remain—it’s recommended you have a good linting setup to find these, and then you can manually fix them up. To help with this, the tool reports freezegun-related usages that it recognizes but cannot migrate, with their positions:

$ python -m time_machine migrate example/tests.py
Rewriting example/tests.py
example/tests.py:9:2: freeze_time usage not migrated

These reports are heuristic and may occasionally flag unrelated code that reuses a freezegun-related name, such as a function parameter that shadows an imported freeze_time.

Files that cannot be parsed are also reported, and otherwise skipped. Since the tool parses files with the Python version it runs on, run it with a version at least as new as the target project uses.

The tool edits files in place, reporting those that it changes. It’s recommended you start from a clean, committed state in your version control system, so you can easily revert any broken changes.

Run with uv

If you have uv installed, you can use its uvx command to install and run the tool in one go:

$ uvx --from 'time-machine[cli]' python -m time_machine migrate example/tests.py

Replace example/tests.py with one or more target files.

Run directly

To install the tool before using it, first install time-machine with its cli extra. For example, with Pip:

$ python -m pip install time-machine[cli]

Then, run the migrate subcommand of the module on target files:

$ python -m time_machine migrate example/tests.py
Rewriting example/tests.py

Replace example/tests.py with one or more target files.

Run against multiple files

To run the tool against all files from your Git repository, follow this blog post.

Changes

The tool makes the below changes, grouped here by the kind of code they apply to.

Import updates

  • import freezegun -> import time_machine:

    -import freezegun
    +import time_machine
    
  • from freezegun import freeze_time -> import time_machine:

    -from freezegun import freeze_time
    +import time_machine
    
  • Aliased imports like import freezegun as fg or from freezegun import freeze_time as ft -> import time_machine. The alias is dropped, since calls using it are migrated to use the time_machine module, per the below:

    -import freezegun as fg
    +import time_machine
    
    -@fg.freeze_time("2023-01-01")
    +@time_machine.travel("2023-01-01", tick=False)
     def test_function():
         ...
    
  • from freezegun import freeze_time, FakeDate -> import time_machine plus from freezegun import FakeDate, keeping the other imported names:

    -from freezegun import freeze_time, FakeDate
    +import time_machine
    +from freezegun import FakeDate
    

freeze_time() calls

  • In function decorators, class decorators, and context managers: freeze_time(...) -> time_machine.travel(...). This change is applied only when freeze_time() is called with a single positional argument and only supported keyword arguments: tick, tz_offset with a literal zero value, real_asyncio, and ignore. If tick is passed, it is kept as-is, otherwise it is replaced with tick=False (matching freezegun’s default behaviour):

    -@freeze_time("2023-01-01")
    +@time_machine.travel("2023-01-01", tick=False)
     def test_function():
         ...
    
    -@freeze_time("2023-01-01", tick=True)
    +@time_machine.travel("2023-01-01", tick=True)
     def test_function2():
         ...
    

    If no positional argument is passed, None is added as the destination, meaning the current time, matching freezegun’s behaviour of freezing at the current time:

    -@freeze_time()
    +@time_machine.travel(None, tick=False)
     def test_function():
         ...
    

    tz_offset=0 is dropped, since a zero offset has no effect. real_asyncio is dropped, whatever its value, since time-machine does not mock time.monotonic(), so asyncio event loops always see real time. ignore is dropped, since it works around problems with freezegun’s module patching, which time-machine’s C-level mocking doesn’t have.

    -@freeze_time("2023-01-01", tz_offset=0, real_asyncio=True, ignore=["threading"])
    +@time_machine.travel("2023-01-01", tick=False)
     def test_function():
         ...
    
  • “Raw use” assignments that bind freeze_time() to a variable for later start() and stop() calls: the assigned call is migrated as above, since travel() instances have the same start() / stop() interface:

     def test_function():
    -    freezer = freeze_time("2023-01-01")
    +    freezer = time_machine.travel("2023-01-01", tick=False)
         freezer.start()
         ...
         freezer.stop()
    

    This applies to plain variables, like freezer = freeze_time(...), checked within the enclosing function or module, and to self. attributes, checked across the enclosing class, so unittest setUp() / tearDown() patterns are covered, including cleanup registrations like self.addCleanup(self.freezer.stop):

     class TestSomething(TestCase):
         def setUp(self):
    -        self.freezer = freeze_time("2023-01-01")
    +        self.freezer = time_machine.travel("2023-01-01", tick=False)
             self.freezer.start()
             self.addCleanup(self.freezer.stop)
    

    The migration only applies when the variable is used solely for start() and stop(): as calls with no arguments in statements, or as bare references passed as call arguments. Other uses, such as move_to() or tick() calls, prevent migration, since freezegun’s object has other methods with no equivalent on travel().

  • In context managers that bind the result with as, additionally: calls of the bound variable’s tick() method -> shift(), with freezegun’s default delta of one second made explicit. Calls of the move_to() method are left unchanged, since it behaves the same in both libraries:

    -with freeze_time("2023-01-01") as ft:
    +with time_machine.travel("2023-01-01", tick=False) as ft:
         ft.move_to("2023-06-01")
    -    ft.tick()
    +    ft.shift(1)
    

    These changes are only applied when the bound variable is used solely for move_to() calls and tick() calls as statements, since tick() returns the new time whilst shift() returns None, and other freezegun attributes have no equivalent on the object that travel() yields.

pytest.mark.freeze_time marker

The pytest.mark.freeze_time marker from pytest-freezegun or pytest-freezer is migrated: @pytest.mark.freeze_time(...) -> @pytest.mark.time_machine(...), the marker from time-machine’s pytest plugin. This migration uses the same argument handling as for freeze_time() calls:

-@pytest.mark.freeze_time("2023-01-01")
+@pytest.mark.time_machine("2023-01-01", tick=False)
 def test_function():
     ...

As well as in decorators, the marker is migrated in module-level and class-level pytestmark assignments, whether assigned alone or within a list or tuple of markers:

 pytestmark = [
-    pytest.mark.freeze_time("2023-01-01"),
+    pytest.mark.time_machine("2023-01-01", tick=False),
     pytest.mark.django_db,
 ]

freezer fixture

The freezer fixture from pytest-freezegun or pytest-freezer is migrated to the time_machine fixture from time-machine’s pytest plugin. In functions with an argument named freezer, the argument is renamed to time_machine and calls of the fixture’s methods are migrated:

  • freezer.move_to(...) -> time_machine.move_to(..., tick=False), again matching freezegun’s default behaviour:

    -def test_function(freezer):
    -    freezer.move_to("2023-01-01")
    +def test_function(time_machine):
    +    time_machine.move_to("2023-01-01", tick=False)
    

    tick=False isn’t added in functions using a migrated pytest.mark.freeze_time marker, since there the fixture inherits the tick behaviour from the marker:

    -@pytest.mark.freeze_time("2023-01-01")
    -def test_function(freezer):
    -    freezer.move_to("2023-06-01")
    +@pytest.mark.time_machine("2023-01-01", tick=False)
    +def test_function(time_machine):
    +    time_machine.move_to("2023-06-01")
    
  • freezer.tick() -> time_machine.shift(1), as for context manager variables, again only for calls as statements:

    -def test_function(freezer):
    -    freezer.tick()
    +def test_function(time_machine):
    +    time_machine.shift(1)
    

Other uses of freezer are left unchanged, for your linter to flag. freeze_time() calls within such functions are also left unchanged, because the renamed argument shadows the time_machine module.

Imports and uses of FrozenDateTimeFactory, freezegun’s class for the freezer fixture, often used to annotate the fixture argument, are also migrated: uses, including in string annotations, are rewritten to time-machine’s equivalent, TimeMachineFixture, and from time_machine import TimeMachineFixture replaces the freezegun import:

-from freezegun.api import FrozenDateTimeFactory
+from time_machine import TimeMachineFixture

-def test_function(freezer: FrozenDateTimeFactory):
-    freezer.move_to("2023-01-01")
+def test_function(time_machine: TimeMachineFixture):
+    time_machine.move_to("2023-01-01", tick=False)

Note that the time_machine fixture doesn’t mock the time until its move_to() method is called, unlike freezer, which mocks from the start of the test. Migrated tests that relied on that, for example by calling freezer.tick() before any move_to(), will need manual adjustment.

The tool is open to extension to cover other compatible changes—PRs welcome!