🐍
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 ofpytz
- • 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
🔗