🐍

Python Time Handling Guide

Master Python's datetime, zoneinfo, and timezone handling for robust time operations in Python applications.

Use

  • datetime.now(tz=timezone.utc) for UTC times
  • zoneinfo.ZoneInfo() for timezone objects
  • .astimezone() for safe conversions
  • datetime.fromisoformat() for parsing
  • fold parameter for DST ambiguity

Avoid

  • datetime.now() without timezone
  • pytz library (deprecated)
  • • Naive datetime arithmetic across DST
  • strptime() without timezone info
  • • Assuming local timezone in production

DateTime vs Timezone-Aware

❌ Naive DateTime (Dangerous)

from datetime import datetime

# Dangerous: No timezone information
now = datetime.now()  # What timezone is this?
print(now)  # 2024-03-10 14:30:00 (ambiguous!)

# Dangerous: Arithmetic across DST boundaries
start = datetime(2024, 3, 10, 1, 30)  # Spring forward day
end = start + timedelta(hours=2)      # May not exist!
print(end)  # 2024-03-10 03:30:00 (skipped 2:30 AM!)

# Dangerous: Parsing without timezone
parsed = datetime.strptime("2024-03-10T02:30:00", "%Y-%m-%dT%H:%M:%S")
# This time doesn't exist in many timezones!

✅ Timezone-Aware (Safe)

from datetime import datetime, timezone
from zoneinfo import ZoneInfo

# Safe: Always specify timezone
utc_now = datetime.now(timezone.utc)
local_now = datetime.now(ZoneInfo("America/New_York"))

# Safe: Convert between timezones
ny_time = utc_now.astimezone(ZoneInfo("America/New_York"))
tokyo_time = utc_now.astimezone(ZoneInfo("Asia/Tokyo"))

# Safe: Parse with timezone information
iso_string = "2024-03-10T02:30:00-05:00"
parsed = datetime.fromisoformat(iso_string)
print(f"Timezone: {parsed.tzinfo}")  # timezone.utc

# Safe: Handle DST ambiguity with fold parameter
# Fall back: 1:30 AM occurs twice
ambiguous_time = datetime(2024, 11, 3, 1, 30, 
                         tzinfo=ZoneInfo("America/New_York"), 
                         fold=0)  # First occurrence
🌍

Modern Timezone Handling (Python 3.9+)

ZoneInfo vs pytz

# ✅ Modern approach (Python 3.9+)
from zoneinfo import ZoneInfo
from datetime import datetime, timezone

# Create timezone-aware datetime
ny_tz = ZoneInfo("America/New_York")
now_ny = datetime.now(ny_tz)

# Safe timezone conversion
utc_time = now_ny.astimezone(timezone.utc)
tokyo_time = utc_time.astimezone(ZoneInfo("Asia/Tokyo"))

# ❌ Old approach (deprecated)
import pytz  # Don't use this anymore!

# pytz has confusing localize() vs replace() semantics
ny_tz_old = pytz.timezone('America/New_York')
# This is error-prone and confusing

Handling DST Transitions

from datetime import datetime
from zoneinfo import ZoneInfo

ny_tz = ZoneInfo("America/New_York")

# Spring forward: 2:30 AM doesn't exist
try:
    nonexistent = datetime(2024, 3, 10, 2, 30, tzinfo=ny_tz)
except ValueError as e:
    print(f"Error: {e}")  # Time doesn't exist

# Fall back: 1:30 AM happens twice
first_occurrence = datetime(2024, 11, 3, 1, 30, tzinfo=ny_tz, fold=0)
second_occurrence = datetime(2024, 11, 3, 1, 30, tzinfo=ny_tz, fold=1)

print(f"First:  {first_occurrence} (UTC: {first_occurrence.utctimetuple()})")
print(f"Second: {second_occurrence} (UTC: {second_occurrence.utctimetuple()})")

# Safe arithmetic: work in UTC
utc_start = datetime(2024, 3, 10, 6, 30, tzinfo=timezone.utc)  # 1:30 AM EST
utc_end = utc_start + timedelta(hours=2)  # Always safe in UTC
local_end = utc_end.astimezone(ny_tz)  # Convert back to local
📝

Parsing and Formatting

Safe Parsing Patterns

from datetime import datetime, timezone
from zoneinfo import ZoneInfo
import re

def safe_parse_iso(iso_string: str) -> datetime:
    """Safely parse ISO 8601 strings with timezone info."""
    try:
        # fromisoformat handles most ISO 8601 formats
        return datetime.fromisoformat(iso_string)
    except ValueError:
        # Handle edge cases or custom formats
        return None

# Examples
examples = [
    "2024-03-10T14:30:00Z",           # UTC
    "2024-03-10T14:30:00+00:00",      # UTC with offset
    "2024-03-10T09:30:00-05:00",      # EST
    "2024-03-10T14:30:00.123456Z",    # With microseconds
]

for example in examples:
    parsed = safe_parse_iso(example)
    if parsed:
        print(f"{example} -> {parsed} (UTC: {parsed.astimezone(timezone.utc)})")

# Convert string timezone to ZoneInfo
def parse_with_timezone(dt_string: str, tz_string: str) -> datetime:
    """Parse datetime string and apply timezone."""
    naive_dt = datetime.fromisoformat(dt_string)
    tz = ZoneInfo(tz_string)
    return naive_dt.replace(tzinfo=tz)

# Example usage
dt = parse_with_timezone("2024-03-10T14:30:00", "America/New_York")
print(f"Local: {dt}, UTC: {dt.astimezone(timezone.utc)}")

Safe Formatting

from datetime import datetime, timezone
from zoneinfo import ZoneInfo

now_utc = datetime.now(timezone.utc)
now_ny = now_utc.astimezone(ZoneInfo("America/New_York"))

# ISO 8601 formatting (recommended for APIs)
iso_format = now_utc.isoformat()
print(f"ISO 8601: {iso_format}")  # 2024-03-10T19:30:00+00:00

# Custom formatting with timezone info
custom_format = now_ny.strftime("%Y-%m-%d %H:%M:%S %Z %z")
print(f"Custom: {custom_format}")  # 2024-03-10 14:30:00 EST -0500

# For databases (always store in UTC)
db_format = now_utc.strftime("%Y-%m-%d %H:%M:%S")
print(f"Database: {db_format}")  # 2024-03-10 19:30:00

# Human-readable with timezone name
human_format = now_ny.strftime("%B %d, %Y at %I:%M %p %Z")
print(f"Human: {human_format}")  # March 10, 2024 at 02:30 PM EST
🧪

Testing Time Logic

Mocking Time for Tests

import pytest
from datetime import datetime, timezone
from unittest.mock import patch
from zoneinfo import ZoneInfo

# Test fixture for consistent time
@pytest.fixture
def fixed_time():
    return datetime(2024, 3, 10, 14, 30, 0, tzinfo=timezone.utc)

# Mock datetime.now() for deterministic tests
def test_time_dependent_function(fixed_time):
    with patch('datetime.datetime') as mock_datetime:
        mock_datetime.now.return_value = fixed_time
        mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)
        
        # Your time-dependent code here
        result = some_function_that_uses_now()
        assert result.hour == 14

# Test DST transitions
def test_dst_transition():
    ny_tz = ZoneInfo("America/New_York")
    
    # Test spring forward (2:30 AM doesn't exist)
    with pytest.raises(ValueError):
        datetime(2024, 3, 10, 2, 30, tzinfo=ny_tz)
    
    # Test fall back (1:30 AM happens twice)
    first = datetime(2024, 11, 3, 1, 30, tzinfo=ny_tz, fold=0)
    second = datetime(2024, 11, 3, 1, 30, tzinfo=ny_tz, fold=1)
    
    assert first != second
    assert first.utctimetuple() != second.utctimetuple()

Evil Test Cases

import pytest
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo

class TestEvilTimeScenarios:
    """Test cases for common time handling bugs."""
    
    def test_spring_forward_gap(self):
        """Test handling of non-existent times during spring DST."""
        ny_tz = ZoneInfo("America/New_York")
        
        # 2:30 AM doesn't exist on spring forward day
        with pytest.raises(ValueError):
            datetime(2024, 3, 10, 2, 30, tzinfo=ny_tz)
    
    def test_fall_back_ambiguity(self):
        """Test handling of ambiguous times during fall DST."""
        ny_tz = ZoneInfo("America/New_York")
        
        # 1:30 AM happens twice
        first = datetime(2024, 11, 3, 1, 30, tzinfo=ny_tz, fold=0)
        second = datetime(2024, 11, 3, 1, 30, tzinfo=ny_tz, fold=1)
        
        # Same local time, different UTC times
        assert first.hour == second.hour
        assert first.utctimetuple() != second.utctimetuple()
    
    def test_leap_year_edge_cases(self):
        """Test February 29th handling."""
        # Leap year
        feb29_2024 = datetime(2024, 2, 29, 12, 0, tzinfo=timezone.utc)
        assert feb29_2024.day == 29
        
        # Non-leap year
        with pytest.raises(ValueError):
            datetime(2023, 2, 29, 12, 0, tzinfo=timezone.utc)
    
    def test_timezone_arithmetic(self):
        """Test arithmetic across timezone boundaries."""
        ny_tz = ZoneInfo("America/New_York")
        
        # Start before DST transition
        start = datetime(2024, 3, 10, 1, 0, tzinfo=ny_tz)
        
        # Add 2 hours - should handle DST transition
        end_utc = start.astimezone(timezone.utc) + timedelta(hours=2)
        end_local = end_utc.astimezone(ny_tz)
        
        # Local time jumps from 1:00 AM to 3:00 AM (skipping 2:00 AM)
        assert end_local.hour == 3
⚠️

Common Pitfalls

1. Naive DateTime Arithmetic

# ❌ Dangerous: Naive arithmetic across DST
start = datetime(2024, 3, 10, 1, 30)  # No timezone!
end = start + timedelta(hours=2)      # May skip or duplicate hours

# ✅ Safe: Work in UTC, convert to local
start_utc = datetime(2024, 3, 10, 6, 30, tzinfo=timezone.utc)
end_utc = start_utc + timedelta(hours=2)
end_local = end_utc.astimezone(ZoneInfo("America/New_York"))

2. Assuming Local Timezone

# ❌ Dangerous: Assumes server timezone
now = datetime.now()  # What timezone is this?

# ✅ Safe: Always be explicit
now_utc = datetime.now(timezone.utc)
now_local = datetime.now(ZoneInfo("America/New_York"))

3. Ignoring DST Ambiguity

# ❌ Dangerous: Ambiguous time during fall-back
ambiguous = datetime(2024, 11, 3, 1, 30, tzinfo=ZoneInfo("America/New_York"))
# Which 1:30 AM? First or second occurrence?

# ✅ Safe: Use fold parameter
first_130 = datetime(2024, 11, 3, 1, 30, tzinfo=ZoneInfo("America/New_York"), fold=0)
second_130 = datetime(2024, 11, 3, 1, 30, tzinfo=ZoneInfo("America/New_York"), fold=1)

Best Practices

Storage & APIs

  • • Always store timestamps in UTC
  • • Use ISO 8601 format for APIs
  • • Include timezone info in responses
  • • Validate timezone names against IANA database
  • • Use datetime.fromisoformat() for parsing

Application Logic

  • • Perform arithmetic in UTC
  • • Convert to local timezone only for display
  • • Handle DST transitions explicitly
  • • Use fold parameter for ambiguous times
  • • Test with multiple timezones

Development

  • • Use zoneinfo instead of pytz
  • • Mock time in tests for consistency
  • • Test DST transition edge cases
  • • Use type hints: datetime
  • • Document timezone assumptions

Production

  • • Set server timezone to UTC
  • • Monitor timezone database updates
  • • Log timestamps with timezone info
  • • Handle timezone changes gracefully
  • • Alert on DST transition periods
📚

Quick Reference

# Essential imports
from datetime import datetime, timezone, timedelta
from zoneinfo import ZoneInfo

# Current time (always specify timezone)
utc_now = datetime.now(timezone.utc)
local_now = datetime.now(ZoneInfo("America/New_York"))

# Timezone conversion
ny_time = utc_now.astimezone(ZoneInfo("America/New_York"))
tokyo_time = utc_now.astimezone(ZoneInfo("Asia/Tokyo"))

# Parsing with timezone
iso_dt = datetime.fromisoformat("2024-03-10T14:30:00-05:00")
naive_dt = datetime.fromisoformat("2024-03-10T14:30:00")
aware_dt = naive_dt.replace(tzinfo=ZoneInfo("America/New_York"))

# Safe arithmetic (work in UTC)
start_utc = datetime.now(timezone.utc)
end_utc = start_utc + timedelta(hours=2)
end_local = end_utc.astimezone(ZoneInfo("America/New_York"))

# DST handling
ambiguous = datetime(2024, 11, 3, 1, 30, 
                    tzinfo=ZoneInfo("America/New_York"), 
                    fold=0)  # First occurrence

# Formatting
iso_string = dt.isoformat()  # 2024-03-10T14:30:00-05:00
custom_string = dt.strftime("%Y-%m-%d %H:%M:%S %Z")  # 2024-03-10 14:30:00 EST
🔗

Related Resources