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:

Featurepytzzoneinfo
Standard libraryNoYes (3.9+)
Localize patterntz.localize(dt) (required)dt.replace(tzinfo=tz) (standard)
Normalize after arithmetictz.normalize(dt) (required)Automatic
Data sourceBundledSystem 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

  1. datetime.now() without timezone — returns naive local time, not UTC
  2. datetime.utcnow() — deprecated in 3.12; returns naive datetime labeled as UTC but without tzinfo
  3. Comparing naive and aware datetimes — raises TypeError
  4. DST arithmeticdt + timedelta(hours=24) ≠ “same time tomorrow” during DST transitions
  5. strptime performance — use fromisoformat() when possible
  6. 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.

pythonstandard-librarydates

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.