that testing our code is a crucial and essential part of the software development life cycle. This is perhaps even more important when we’re discussing AI and ML systems, where an inherent uncertainty and hallucinatory element are potentially already baked in from the outset.

And within that general testing framework, testing code that behaves differently based on the current date or time can be a real headache. How do you reliably check logic that triggers only at midnight, calculates relative dates (“2 hours ago”), or handles tricky situations like leap years or month-ends? Manually mocking Python’s datetime module can be cumbersome and error-prone.

If you’ve ever wrestled with this, you’re not alone. But what if you could simply … stop time? Or even travel through it within your tests?

That’s precisely what the Freezegun library lets you do. It’s an elegant solution to a common testing problem, yet many experienced Python developers have never heard of it.

Freezegun allows your Python tests to simulate specific moments in time by mocking the datetime, date, time, and pendulum Python modules. It’s simple to use but powerful for creating deterministic and reliable tests for time-sensitive code.

Why is Freezegun so helpful?

  1. Determinism. This is Freezegun’s primary benefit. Tests involving time become entirely predictable. Running datetime.now() inside a frozen block returns the same frozen timestamp, eliminating flaky tests caused by millisecond differences or date rollovers during test execution.
  2. Simplicity. Compared to manually patching datetime.now or using unittest.mock, Freezegun is often much cleaner and requires less boilerplate code, especially when temporarily changing the time.
  3. Time Travel. Easily simulate specific dates and times — past, present, or future. This is crucial for testing edge cases, such as year-end processing, leap seconds, daylight saving time transitions, or simply verifying logic tied to specific events.
  4. Relative Time Testing. Test functions that calculate relative times (e.g., “expires in 3 days”) by freezing time and creating timestamps relative to that frozen moment.
  5. Tick Tock. Freezegun allows time to advance (“tick”) from the frozen moment within a test, which is perfect for testing timeouts, durations, or sequences of time-dependent events.

Hopefully, I’ve convinced you that Freezegun could be a valuable addition to your Python toolbox. Let’s see it in action by looking through some sample code snippets.

Setting up a dev environment

But before that, let’s set up a development environment to experiment with. I use Miniconda for this, but you can use any tool with which you’re familiar.

I’m a Windows user, but I often develop using WSL2 Ubuntu for Windows, which is what I’ll be doing here.

All the code I show should work equally well under Windows or Unix-like operating systems.

# Create and activate a new dev environment
#
(base) $ conda create -n freezegun python=3.12 -y
(base) $ conda activate freezegun

Now, we can install the remaining necessary libraries.

(freezegun) $ pip install freezegun jupyter

I’ll be using Jupyter Notebook to run my code. To follow along, type jupyter notebook into your command prompt. You should see a jupyter notebook open in your browser. If that doesn’t happen automatically, you’ll likely see a screenful of information after the jupyter notebook command. Near the bottom, you will find a URL to copy and paste into your browser to launch the Jupyter Notebook.

Your URL will be different to mine, but it should look something like this:-

http://127.0.0.1:8888/tree?token=3b9f7bd07b6966b41b68e2350721b2d0b6f388d248cc69da

A quick aside: The code I’m showing in my examples below makes heavy use of the Python assert command. If you haven’t come across this function before or haven’t done much unit testing in Python, assert is used to test if a condition is true, and if it isn’t, it raises an AssertionError.This helps catch issues during development and is commonly used for debugging and validating assumptions in the code.

Example 1: Basic Time Freezing using a Decorator

The most common way to use Freezegun is via its decorator@freeze_time, which allows you to “set” a particular time of day to test various time-related functions.

import datetime
from freezegun import freeze_time

def get_greeting():
    now = datetime.datetime.now()
    print(f"  Inside get_greeting(), now = {now}") # Added print
    if now.hour < 12:
        return "Good morning!"
    elif 12 <= now.hour < 18:
        return "Good afternoon!"
    else:
        return "Good evening!"

# Test the morning greeting
@freeze_time("2023-10-27 09:00:00")
def test_morning_greeting():
    print("Running test_morning_greeting:")
    greeting = get_greeting()
    print(f"  -> Got greeting: '{greeting}'")
    assert greeting == "Good morning!"

# Test the evening greeting
@freeze_time("2023-10-27 21:30:00")
def test_evening_greeting():
    print("\nRunning test_evening_greeting:")
    greeting = get_greeting()
    print(f"  -> Got greeting: '{greeting}'")
    assert greeting == "Good evening!"

# Run the tests
test_morning_greeting()
test_evening_greeting()
print("\nBasic decorator tests passed!")

# --- Failure Scenario ---
# What happens if we don't freeze time?
print("\n--- Running without freeze_time (might fail depending on actual time) ---")
def test_morning_greeting_unfrozen():
    print("Running test_morning_greeting_unfrozen:")
    greeting = get_greeting()
    print(f"  -> Got greeting: '{greeting}'")
    # This assertion is now unreliable! It depends on when you run the code.
    try:
        assert greeting == "Good morning!" 
        print("  (Passed by chance)")
    except AssertionError:
        print("  (Failed as expected - time wasn't 9 AM)")

test_morning_greeting_unfrozen()

And the output.

Running test_morning_greeting:
  Inside get_greeting(), now = 2023-10-27 09:00:00
  -> Got greeting: 'Good morning!'

Running test_evening_greeting:
  Inside get_greeting(), now = 2023-10-27 21:30:00
  -> Got greeting: 'Good evening!'

Basic decorator tests passed!

--- Running without freeze_time (might fail depending on actual time) ---
Running test_morning_greeting_unfrozen:
  Inside get_greeting(), now = 2025-04-16 15:00:37.363367
  -> Got greeting: 'Good afternoon!'
  (Failed as expected - time wasn't 9 AM)

Example 2: Basic Time Freezing using a Context Manager

Create a “block” of frozen time.

import datetime
from freezegun import freeze_time

def process_batch_job():
    start_time = datetime.datetime.now()
    # Simulate work
    end_time = datetime.datetime.now() # In reality, time would pass
    print(f"  Inside job: Start={start_time}, End={end_time}") # Added print
    return (start_time, end_time)

def test_job_timestamps_within_frozen_block():
    print("\nRunning test_job_timestamps_within_frozen_block:")
    frozen_time_str = "2023-11-15 10:00:00"
    with freeze_time(frozen_time_str):
        print(f"  Entering frozen block at {frozen_time_str}")
        start, end = process_batch_job()
        
        print(f"  Asserting start == end: {start} == {end}")
        assert start == end
        print(f"  Asserting start == frozen time: {start} == {datetime.datetime(2023, 11, 15, 10, 0, 0)}")
        assert start == datetime.datetime(2023, 11, 15, 10, 0, 0)
        print("  Assertions inside block passed.")
        
    print("  Exited frozen block.")
    now_outside = datetime.datetime.now()
    print(f"  Time outside block: {now_outside} (should be real time)")
    # This assertion just shows time is unfrozen, value depends on real time
    assert now_outside != datetime.datetime(2023, 11, 15, 10, 0, 0)

test_job_timestamps_within_frozen_block()
print("\nContext manager test passed!")

The output.

 Running test_job_timestamps_within_frozen_block:
 Entering frozen block at 2023-11-15 10:00:00
 Inside job: Start=2023-11-15 10:00:00, End=2023-11-15 10:00:00
 Asserting start == end: 2023-11-15 10:00:00 == 2023-11-15 10:00:00
 Asserting start == frozen time: 2023-11-15 10:00:00 == 2023-11-15 10:00:00
 Assertions inside block passed.
 Exited frozen block.
 Time outside block: 2025-04-16 15:10:15.231632 (should be real time)

 Context manager test passed!

Example 3: Advancing Time with tick

Simulate time passing within a frozen period.

import datetime
import time
from freezegun import freeze_time

def check_if_event_expired(event_timestamp, expiry_duration_seconds):
    now = datetime.datetime.now()
    expired = now > event_timestamp + datetime.timedelta(seconds=expiry_duration_seconds)
    print(f"  Checking expiry: Now={now}, Event={event_timestamp}, ExpiresAt={event_timestamp + datetime.timedelta(seconds=expiry_duration_seconds)} -> Expired={expired}")
    return expired

# --- Manual ticking using context manager ---
def test_event_expiry_manual_tick():
    print("\nRunning test_event_expiry_manual_tick:")

    with freeze_time("2023-10-27 12:00:00") as freezer:
        event_time_in_freeze = datetime.datetime.now()
        expiry_duration = 60
        print(f"  Event created at: {event_time_in_freeze}")

        print("  Checking immediately after creation:")
        assert not check_if_event_expired(event_time_in_freeze, expiry_duration)

        # Advance time by 61 seconds
        delta_to_tick = datetime.timedelta(seconds=61)
        print(f"  Ticking forward by {delta_to_tick}...")
        freezer.tick(delta=delta_to_tick)

        print(f"  Time after ticking: {datetime.datetime.now()}")
        print("  Checking after ticking:")
        assert check_if_event_expired(event_time_in_freeze, expiry_duration)

        print("  Manual tick test finished.")

# --- Failure Scenario ---
@freeze_time("2023-10-27 12:00:00")  # No tick=True or manual tick
def test_event_expiry_fail_without_tick():
    print("\n--- Running test_event_expiry_fail_without_tick (EXPECT ASSERTION ERROR) ---")
    event_time = datetime.datetime.now()
    expiry_duration = 60
    print(f"  Event created at: {event_time}")

    # Simulate work or waiting - without tick, time doesn't advance!
    time.sleep(0.1)

    print(f"  Time after simulated wait: {datetime.datetime.now()}")
    print("  Checking expiry (incorrectly, time didn't move):")
    try:
        # This should ideally be True, but will be False without ticking
        assert check_if_event_expired(event_time, expiry_duration)
    except AssertionError:
        print("  AssertionError: Event did not expire, as expected without tick.")
    print("  Failure scenario finished.")

# Run both tests
test_event_expiry_manual_tick()
test_event_expiry_fail_without_tick()

This outputs the following.

Running test_event_expiry_manual_tick:
  Event created at: 2023-10-27 12:00:00
  Checking immediately after creation:
  Checking expiry: Now=2023-10-27 12:00:00, Event=2023-10-27 12:00:00, ExpiresAt=2023-10-27 12:01:00 -> Expired=False
  Ticking forward by 0:01:01...
  Time after ticking: 2023-10-27 12:01:01
  Checking after ticking:
  Checking expiry: Now=2023-10-27 12:01:01, Event=2023-10-27 12:00:00, ExpiresAt=2023-10-27 12:01:00 -> Expired=True
  Manual tick test finished.

--- Running test_event_expiry_fail_without_tick (EXPECT ASSERTION ERROR) ---
  Event created at: 2023-10-27 12:00:00
  Time after simulated wait: 2023-10-27 12:00:00
  Checking expiry (incorrectly, time didn't move):
  Checking expiry: Now=2023-10-27 12:00:00, Event=2023-10-27 12:00:00, ExpiresAt=2023-10-27 12:01:00 -> Expired=False
  AssertionError: Event did not expire, as expected without tick.
  Failure scenario finished.

Example 4: Testing Relative Dates

Freezegun ensures stable “time ago” logic.

import datetime
from freezegun import freeze_time

def format_relative_time(timestamp):
    now = datetime.datetime.now()
    delta = now - timestamp
    
    rel_time_str = ""
    if delta.days > 0:
        rel_time_str = f"{delta.days} days ago"
    elif delta.seconds >= 3600:
        hours = delta.seconds // 3600
        rel_time_str = f"{hours} hours ago"
    elif delta.seconds >= 60:
        minutes = delta.seconds // 60
        rel_time_str = f"{minutes} minutes ago"
    else:
        rel_time_str = "just now"
    print(f"  Formatting relative time: Now={now}, Timestamp={timestamp} -> '{rel_time_str}'")
    return rel_time_str

@freeze_time("2023-10-27 15:00:00")
def test_relative_time_formatting():
    print("\nRunning test_relative_time_formatting:")
    
    # Event happened 2 days and 3 hours ago relative to frozen time
    past_event = datetime.datetime(2023, 10, 25, 12, 0, 0)
    assert format_relative_time(past_event) == "2 days ago"

    # Event happened 45 minutes ago
    recent_event = datetime.datetime.now() - datetime.timedelta(minutes=45)
    assert format_relative_time(recent_event) == "45 minutes ago"

    # Event happened just now
    current_event = datetime.datetime.now() - datetime.timedelta(seconds=10)
    assert format_relative_time(current_event) == "just now"
    
    print("  Relative time tests passed!")

test_relative_time_formatting()

# --- Failure Scenario ---
print("\n--- Running relative time without freeze_time (EXPECT FAILURE) ---")
def test_relative_time_unfrozen():
    # Use the same past event timestamp
    past_event = datetime.datetime(2023, 10, 25, 12, 0, 0) 
    print(f"  Testing with past_event = {past_event}")
    # This will compare against the *actual* current time, not Oct 27th, 2023
    formatted_time = format_relative_time(past_event)
    try:
        assert formatted_time == "2 days ago" 
    except AssertionError:
        # The actual difference will be much larger!
        print(f"  AssertionError: Expected '2 days ago', but got '{formatted_time}'. Failed as expected.")

test_relative_time_unfrozen()

The output.

Running test_relative_time_formatting:
  Formatting relative time: Now=2023-10-27 15:00:00, Timestamp=2023-10-25 12:00:00 -> '2 days ago'
  Formatting relative time: Now=2023-10-27 15:00:00, Timestamp=2023-10-27 14:15:00 -> '45 minutes ago'
  Formatting relative time: Now=2023-10-27 15:00:00, Timestamp=2023-10-27 14:59:50 -> 'just now'
  Relative time tests passed!

--- Running relative time without freeze_time (EXPECT FAILURE) ---
  Testing with past_event = 2023-10-25 12:00:00
  Formatting relative time: Now=2023-10-27 12:00:00, Timestamp=2023-10-25 12:00:00 -> '2 days ago'

Example 5: Handling Specific Dates (End of Month)

Test edge cases, such as leap years, reliably.

import datetime
from freezegun import freeze_time

def is_last_day_of_month(check_date):
    next_day = check_date + datetime.timedelta(days=1)
    is_last = next_day.month != check_date.month
    print(f"  Checking if {check_date} is last day of month: Next day={next_day}, IsLast={is_last}")
    return is_last

print("\nRunning specific date logic tests:")

@freeze_time("2023-02-28") # Non-leap year
def test_end_of_february_non_leap():
    today = datetime.date.today()
    assert is_last_day_of_month(today) is True

@freeze_time("2024-02-28") # Leap year
def test_end_of_february_leap_not_yet():
     today = datetime.date.today()
     assert is_last_day_of_month(today) is False # Feb 29th exists

@freeze_time("2024-02-29") # Leap year - last day
def test_end_of_february_leap_actual():
    today = datetime.date.today()
    assert is_last_day_of_month(today) is True

@freeze_time("2023-12-31")
def test_end_of_year():
    today = datetime.date.today()
    assert is_last_day_of_month(today) is True

test_end_of_february_non_leap()
test_end_of_february_leap_not_yet()
test_end_of_february_leap_actual()
test_end_of_year()
print("Specific date logic tests passed!")



#
# Output
#


Running specific date logic tests:
Checking if 2023-02-28 is last day of month: Next day=2023-03-01, IsLast=True
Checking if 2024-02-28 is last day of month: Next day=2024-02-29, IsLast=False
Checking if 2024-02-29 is last day of month: Next day=2024-03-01, IsLast=True
Checking if 2023-12-31 is last day of month: Next day=2024-01-01, IsLast=True
pecific date logic tests passed!

Example 6: Time Zones

Test timezone-aware code correctly, handling offsets and transitions like BST/GMT.

# Requires Python 3.9+ for zoneinfo or `pip install pytz` for older versions
import datetime
from freezegun import freeze_time
try:
    from zoneinfo import ZoneInfo # Python 3.9+
except ImportError:
    from pytz import timezone as ZoneInfo # Fallback for older Python/pytz

def get_local_and_utc_time():
    # Assume local timezone is Europe/London for this example
    local_tz = ZoneInfo("Europe/London")
    now_utc = datetime.datetime.now(datetime.timezone.utc)
    now_local = now_utc.astimezone(local_tz)
    print(f"  Getting times: UTC={now_utc}, Local={now_local} ({now_local.tzname()})")
    return now_local, now_utc

# Freeze time as 9 AM UTC. London is UTC+1 in summer (BST). Oct 27 is BST.
@freeze_time("2023-10-27 09:00:00", tz_offset=0) # tz_offset=0 means the frozen time string IS UTC
def test_time_in_london_bst():
    print("\nRunning test_time_in_london_bst:")
    local_time, utc_time = get_local_and_utc_time()
    assert utc_time.hour == 9
    assert local_time.hour == 10 # London is UTC+1 on this date
    assert local_time.tzname() == "BST" 

# Freeze time as 9 AM UTC. Use December 27th, which is GMT (UTC+0)
@freeze_time("2023-12-27 09:00:00", tz_offset=0)
def test_time_in_london_gmt():
    print("\nRunning test_time_in_london_gmt:")
    local_time, utc_time = get_local_and_utc_time()
    assert utc_time.hour == 9
    assert local_time.hour == 9 # London is UTC+0 on this date
    assert local_time.tzname() == "GMT"

test_time_in_london_bst()
test_time_in_london_gmt()
print("\nTimezone tests passed!")

#
# Output
#

 Running test_time_in_london_bst:
 Getting times: UTC=2023-10-27 09:00:00+00:00, Local=2023-10-27 10:00:00+01:00 (BST)

 Running test_time_in_london_gmt:
 Getting times: UTC=2023-12-27 09:00:00+00:00, Local=2023-12-27 09:00:00+00:00 (GMT)

 Timezone tests passed!

Example 7: Explicit Time Travel with the move_to function

Jump between specific time points in a single test for complex temporal sequences.

import datetime
from freezegun import freeze_time

class ReportGenerator:
    def __init__(self):
        self.creation_time = datetime.datetime.now()
        self.data = {"status": "pending", "generated_at": None}
        print(f"  Report created at {self.creation_time}")

    def generate(self):
        self.data["status"] = "generated"
        self.data["generated_at"] = datetime.datetime.now()
        print(f"  Report generated at {self.data['generated_at']}")

    def get_status_update(self):
        now = datetime.datetime.now()
        if self.data["status"] == "generated":
            time_since_generation = now - self.data["generated_at"]
            status = f"Generated {time_since_generation.seconds} seconds ago."
        else:
            time_since_creation = now - self.creation_time
            status = f"Pending for {time_since_creation.seconds} seconds."
        print(f"  Status update at {now}: '{status}'")
        return status

def test_report_lifecycle():
    print("\nRunning test_report_lifecycle:")
    with freeze_time("2023-11-01 10:00:00") as freezer:
        report = ReportGenerator()
        assert report.data["status"] == "pending"
        
        # Check status after 5 seconds
        target_time = datetime.datetime(2023, 11, 1, 10, 0, 5)
        print(f"  Moving time to {target_time}")
        freezer.move_to(target_time)
        assert report.get_status_update() == "Pending for 5 seconds."

        # Generate the report at 10:01:00
        target_time = datetime.datetime(2023, 11, 1, 10, 1, 0)
        print(f"  Moving time to {target_time} and generating report")
        freezer.move_to(target_time)
        report.generate()
        assert report.data["status"] == "generated"
        assert report.get_status_update() == "Generated 0 seconds ago."

        # Check status 30 seconds after generation
        target_time = datetime.datetime(2023, 11, 1, 10, 1, 30)
        print(f"  Moving time to {target_time}")
        freezer.move_to(target_time)
        assert report.get_status_update() == "Generated 30 seconds ago."
        
    print("  Complex lifecycle test passed!")

test_report_lifecycle()

# --- Failure Scenario ---
def test_report_lifecycle_fail_forgot_move():
    print("\n--- Running lifecycle test (FAIL - forgot move_to) ---")
    with freeze_time("2023-11-01 10:00:00") as freezer:
        report = ReportGenerator()
        assert report.data["status"] == "pending"
        
        # We INTEND to check status after 5 seconds, but FORGET to move time
        print(f"  Checking status (time is still {datetime.datetime.now()})")
        # freezer.move_to("2023-11-01 10:00:05") # <-- Forgotten!
        try:
            assert report.get_status_update() == "Pending for 5 seconds."
        except AssertionError as e:
            print(f"  AssertionError: {e}. Failed as expected.")
            
test_report_lifecycle_fail_forgot_move()

Here’s the output.

Running test_report_lifecycle:
  Report created at 2023-11-01 10:00:00
  Moving time to 2023-11-01 10:00:05
  Status update at 2023-11-01 10:00:05: 'Pending for 5 seconds.'
  Moving time to 2023-11-01 10:01:00 and generating report
  Report generated at 2023-11-01 10:01:00
  Status update at 2023-11-01 10:01:00: 'Generated 0 seconds ago.'
  Moving time to 2023-11-01 10:01:30
  Status update at 2023-11-01 10:01:30: 'Generated 30 seconds ago.'
  Complex lifecycle test passed!

--- Running lifecycle test (FAIL - forgot move_to) ---
  Report created at 2023-11-01 10:00:00
  Checking status (time is still 2023-11-01 10:00:00)
  Status update at 2023-11-01 10:00:00: 'Pending for 0 seconds.'
  AssertionError: . Failed as expected.

Summary

Freezegun is a fantastic tool for any Python developer who needs to test code involving dates and times. It transforms potentially flaky, hard-to-write tests into simple, robust, and deterministic ones. By allowing you to freeze, tick, and travel through time with ease — and by making it clear when time isn’t controlled — it unlocks the ability to effectively and reliably test previously challenging scenarios.

To illustrate this, I provided several examples covering different instances involving date and time testing and showed how using Freezegun eliminates many of the obstacles that a traditional testing framework might encounter.

While we’ve covered the core functionalities, you can do more with Freezegun, and I recommend checking out its GitHub page.

In short, Freezegun is a library you should know and use if your code deals with time and you need to test it thoroughly and reliably.

Share.

Comments are closed.