datetime Handling — Deep Dive
datetime Internals
Python’s datetime is implemented in C (Modules/_datetimemodule.c) for performance. A datetime object stores year, month, day, hour, minute, second, microsecond, and an optional tzinfo — all as C integers, taking about 48 bytes per instance.
The C implementation means operations like comparison and arithmetic are fast:
import timeit
from datetime import datetime, timedelta
a = datetime(2026, 1, 1)
b = datetime(2026, 12, 31)
timeit.timeit('a < b', globals={'a': a, 'b': b}, number=1_000_000) # ~0.04s
timeit.timeit('a + d', globals={'a': a, 'd': timedelta(days=1)}, number=1_000_000) # ~0.07s
The tzinfo Protocol
The tzinfo abstract base class defines three methods that timezone implementations must provide:
class tzinfo:
def utcoffset(self, dt) -> timedelta:
"""Return UTC offset for this datetime."""
...
def tzname(self, dt) -> str:
"""Return timezone name (e.g., 'EST', 'EDT')."""
...
def dst(self, dt) -> timedelta:
"""Return DST adjustment (timedelta(0) if not in DST)."""
...
The dt parameter matters — the same timezone can have different offsets depending on the date (DST transitions). This is why timezone(timedelta(hours=-5)) is insufficient for “US Eastern” — it doesn’t handle the spring/fall transitions.
Daylight Saving Time Edge Cases
DST transitions create two classes of problems:
1. Non-Existent Times (Spring Forward)
When clocks spring forward, some times don’t exist:
from datetime import datetime
from zoneinfo import ZoneInfo
eastern = ZoneInfo("America/New_York")
# In 2026, DST starts March 8 at 2:00 AM → jumps to 3:00 AM
# 2:30 AM doesn't exist
ambiguous = datetime(2026, 3, 8, 2, 30, tzinfo=eastern)
# zoneinfo handles this by assuming the time that does exist (post-transition)
2. Ambiguous Times (Fall Back)
When clocks fall back, some times occur twice:
# In 2026, DST ends November 1 at 2:00 AM → falls back to 1:00 AM
# 1:30 AM happens twice
from zoneinfo import ZoneInfo
from datetime import datetime
eastern = ZoneInfo("America/New_York")
# First occurrence (EDT, UTC-4)
dt1 = datetime(2026, 11, 1, 1, 30, fold=0, tzinfo=eastern)
# Second occurrence (EST, UTC-5)
dt2 = datetime(2026, 11, 1, 1, 30, fold=1, tzinfo=eastern)
print(dt1.utcoffset()) # -4:00:00
print(dt2.utcoffset()) # -5:00:00
The fold parameter (PEP 495, Python 3.6) disambiguates these cases. fold=0 means the first occurrence (before the transition), fold=1 means the second.
zoneinfo vs pytz
Python 3.9+ ships zoneinfo, which should replace pytz:
| Feature | pytz | zoneinfo |
|---|---|---|
| Standard library | No | Yes (3.9+) |
| Localize pattern | tz.localize(dt) (required) | dt.replace(tzinfo=tz) (standard) |
| Normalize after arithmetic | tz.normalize(dt) (required) | Automatic |
| Data source | Bundled | System IANA database |
Critical pytz gotcha: Using datetime(tzinfo=pytz_tz) directly produces wrong results. You must use pytz_tz.localize(naive_dt). This is the #1 pytz bug:
import pytz
eastern = pytz.timezone("America/New_York")
# WRONG — gives LMT offset (-4:56), not EST (-5:00)
bad = datetime(2026, 1, 1, tzinfo=eastern)
# CORRECT
good = eastern.localize(datetime(2026, 1, 1))
zoneinfo doesn’t have this problem. Just use it.
Timestamp Conversion
datetime ↔ Unix Timestamp
from datetime import datetime, timezone
# datetime → timestamp
dt = datetime(2026, 3, 27, 14, 0, tzinfo=timezone.utc)
ts = dt.timestamp() # 1774792800.0
# timestamp → datetime
dt_back = datetime.fromtimestamp(ts, tz=timezone.utc)
# DANGEROUS: fromtimestamp without tz returns local time
local_dt = datetime.fromtimestamp(ts) # Uses system timezone — avoid!
Always pass tz=timezone.utc to fromtimestamp().
The 2038 Problem
On 32-bit systems, Unix timestamps overflow at 2038-01-19 03:14:07 UTC. Python’s datetime handles dates up to year 9999, but interop with C libraries or databases using 32-bit timestamps can fail.
ISO 8601 and RFC 3339
# Generate ISO 8601
dt = datetime(2026, 3, 27, 14, 0, tzinfo=timezone.utc)
dt.isoformat() # '2026-03-27T14:00:00+00:00'
# Parse ISO 8601 (expanded in Python 3.11)
datetime.fromisoformat("2026-03-27T14:00:00+00:00")
datetime.fromisoformat("2026-03-27T14:00:00Z") # Z suffix works in 3.11+
For pre-3.11 code, “Z” suffix must be handled manually:
def parse_iso(s):
s = s.replace("Z", "+00:00")
return datetime.fromisoformat(s)
Performance Patterns
Avoid strptime in hot paths
strptime is slow because it parses format strings each call:
import timeit
# Slow: strptime
timeit.timeit(
'datetime.strptime("2026-03-27", "%Y-%m-%d")',
globals={'datetime': datetime},
number=100_000
) # ~0.8s
# Fast: manual parsing
timeit.timeit(
'datetime(int(s[:4]), int(s[5:7]), int(s[8:10]))',
globals={'datetime': datetime, 's': '2026-03-27'},
number=100_000
) # ~0.05s
# Fast: fromisoformat
timeit.timeit(
'datetime.fromisoformat(s)',
globals={'datetime': datetime, 's': '2026-03-27'},
number=100_000
) # ~0.03s
fromisoformat() is 25x faster than strptime() for ISO format strings.
Batch timezone conversions
from zoneinfo import ZoneInfo
# Pre-create timezone objects (they're cached internally, but be explicit)
UTC = ZoneInfo("UTC")
EASTERN = ZoneInfo("America/New_York")
def convert_batch(timestamps: list[float]) -> list[datetime]:
return [
datetime.fromtimestamp(ts, tz=UTC).astimezone(EASTERN)
for ts in timestamps
]
Database Integration
PostgreSQL with psycopg
PostgreSQL’s TIMESTAMP WITH TIME ZONE stores UTC internally. psycopg returns aware datetimes:
# Returns datetime with UTC tzinfo
row = cursor.fetchone()
dt = row[0] # datetime(2026, 3, 27, 14, 0, tzinfo=...) — always UTC
# Convert to user's timezone for display
user_dt = dt.astimezone(ZoneInfo("America/Chicago"))
SQLite (stores as strings)
SQLite has no native datetime type. Use ISO 8601 strings and convert:
import sqlite3
# Store
cursor.execute(
"INSERT INTO events (ts) VALUES (?)",
(dt.isoformat(),)
)
# Retrieve
row = cursor.fetchone()
dt = datetime.fromisoformat(row[0])
SQLAlchemy
from sqlalchemy import Column, DateTime
class Event(Base):
# timezone=True → TIMESTAMP WITH TIME ZONE in PostgreSQL
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
Production Pattern: Date Range Generator
from datetime import date, timedelta
from typing import Iterator
def date_range(start: date, end: date, step: int = 1) -> Iterator[date]:
"""Generate dates from start to end (inclusive)."""
current = start
while current <= end:
yield current
current += timedelta(days=step)
# Usage
for d in date_range(date(2026, 1, 1), date(2026, 1, 31)):
print(d)
Production Pattern: Relative Time Display
from datetime import datetime, timezone
def time_ago(dt: datetime) -> str:
"""Human-readable relative time."""
now = datetime.now(timezone.utc)
diff = now - dt
seconds = int(diff.total_seconds())
if seconds < 60:
return "just now"
elif seconds < 3600:
minutes = seconds // 60
return f"{minutes}m ago"
elif seconds < 86400:
hours = seconds // 3600
return f"{hours}h ago"
elif seconds < 2592000:
days = seconds // 86400
return f"{days}d ago"
else:
return dt.strftime("%b %d, %Y")
Common Pitfalls Summary
datetime.now()without timezone — returns naive local time, not UTCdatetime.utcnow()— deprecated in 3.12; returns naive datetime labeled as UTC but without tzinfo- Comparing naive and aware datetimes — raises
TypeError - DST arithmetic —
dt + timedelta(hours=24)≠ “same time tomorrow” during DST transitions - strptime performance — use
fromisoformat()when possible - Storing local times — always store UTC; convert on display
One thing to remember: Python’s datetime works best when you follow one rule: always use aware datetimes in UTC internally (datetime.now(timezone.utc)), and convert to local time only at the display boundary. Use zoneinfo for timezone handling (not pytz), fromisoformat() for parsing (not strptime), and be vigilant about DST transitions — they create non-existent and ambiguous times that silently corrupt data.
See Also
- Python Atexit How Python's atexit module lets your program clean up after itself right before it shuts down.
- Python Bisect Sorted Lists How Python's bisect module finds things in sorted lists the way you'd find a word in a dictionary — by jumping to the middle.
- Python Contextlib How Python's contextlib module makes the 'with' statement work for anything, not just files.
- Python Copy Module Why copying data in Python isn't as simple as it sounds, and how the copy module prevents sneaky bugs.
- Python Dataclass Field Metadata How Python dataclass fields can carry hidden notes — like sticky notes on a filing cabinet that tools read automatically.