Dates, Times, and Time Zones in Python: A Practical Deep Dive into datetime and zoneinfo
Learn to handle dates and times correctly in Python: naive vs. aware datetimes, converting between time zones with the standard-library zoneinfo, parsing and formatting, safe arithmetic, and the DST pitfalls that bite real applications.
Few things trip up developers as reliably as dates and times. A timestamp that looks fine on your laptop turns into a one-hour bug in production, a daily report fires at the wrong moment twice a year, and "just store the time" turns out to mean nothing without a time zone attached. Python's datetime module gives you everything you need to get this right — and since Python 3.9, the standard-library zoneinfo module finally provides real IANA time-zone support with no third-party dependency.
This guide walks through the concepts that actually matter in practice: the crucial difference between naive and aware datetimes, converting between zones, parsing and formatting, doing arithmetic safely, and the daylight-saving-time edge cases that cause real outages. Every snippet here was run on Python 3.11+.
The core types
The datetime module exposes a handful of classes. The three you'll use most are date (a calendar day with no time), time (a wall-clock time with no date), and datetime (both together). Supporting them are timedelta (a duration) and timezone (a fixed UTC offset).
from datetime import datetime, date, time, timedelta, timezone
print(date.today()) # 2026-06-30
print(datetime(2026, 6, 30, 14, 30)) # 2026-06-30 14:30:00
print(timedelta(days=2, hours=3)) # 2 days, 3:00:00
That's the easy part. The single most important distinction in the whole module is the next one.
Naive vs. aware datetimes
A naive datetime has no time-zone information attached — its tzinfo is None. It represents a wall-clock reading with no way to know which wall it was on. An aware datetime carries a tzinfo, so it unambiguously identifies a moment in time.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
naive = datetime(2026, 6, 30, 14, 30)
print(naive.tzinfo) # None -> naive
aware = datetime(2026, 6, 30, 14, 30, tzinfo=ZoneInfo("America/New_York"))
print(aware.isoformat()) # 2026-06-30T14:30:00-04:00 -> aware
The practical rule: use aware datetimes anywhere a value crosses a boundary — stored in a database, sent over the network, compared against "now," or shown to users in another region. Naive datetimes are fine only for purely local, throwaway logic. Mixing the two raises TypeError the moment you try to subtract or compare them, which is Python protecting you from a silent bug.
Getting the current time correctly
You'll often see datetime.now() in tutorials. It returns a naive local time, which is almost never what you want for anything persistent. Reach for an explicit zone instead:
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
# Best default for storage and logging: aware UTC
now_utc = datetime.now(timezone.utc)
print(now_utc.isoformat()) # 2026-06-30T18:30:00.123456+00:00
# Current time in a specific zone
now_tokyo = datetime.now(ZoneInfo("Asia/Tokyo"))
print(now_tokyo.isoformat())
A common convention is to store everything in UTC and convert to a local zone only at the moment you display it. UTC has no daylight-saving transitions, so arithmetic and comparisons stay simple.
Time zones with zoneinfo
Before Python 3.9 you needed the third-party pytz package and its awkward localize() dance. Now zoneinfo.ZoneInfo reads the operating system's IANA time-zone database directly, and you attach it like any other tzinfo. Converting between zones is a single call to astimezone():
from datetime import datetime
from zoneinfo import ZoneInfo
ny = datetime(2026, 6, 30, 14, 30, tzinfo=ZoneInfo("America/New_York"))
tokyo = ny.astimezone(ZoneInfo("Asia/Tokyo"))
print(ny.isoformat()) # 2026-06-30T14:30:00-04:00
print(tokyo.isoformat()) # 2026-07-01T03:30:00+09:00
Both values point to the same instant; only the wall-clock representation differs. Use canonical IANA names like "Europe/Berlin" or "America/Sao_Paulo" — not fixed offsets like "UTC+2" — because only the named zone knows when daylight-saving transitions happen.
On some minimal systems (notably Windows or stripped-down containers) the OS may not ship the tz database. If ZoneInfo raises ZoneInfoNotFoundError, install the fallback data with pip install tzdata; zoneinfo picks it up automatically.
Parsing strings into datetimes
Two tools cover almost every case. For ISO 8601 — the format you should prefer for data interchange — use fromisoformat(), which is fast and round-trips with isoformat():
from datetime import datetime
dt = datetime.fromisoformat("2026-06-30T14:30:00+02:00")
print(dt) # 2026-06-30 14:30:00+02:00
print(dt.tzinfo) # UTC+02:00
For other formats, strptime() parses according to a directive string:
from datetime import datetime
dt = datetime.strptime("30/06/2026 14:30", "%d/%m/%Y %H:%M")
print(dt) # 2026-06-30 14:30:00 (naive)
Note that strptime usually yields a naive datetime unless your format includes an offset (%z). If you know the zone the string was recorded in, attach it explicitly afterward with .replace(tzinfo=ZoneInfo(...)).
Formatting datetimes for output
strftime() is the inverse of strptime() and turns a datetime into a string using the same directives:
from datetime import datetime
from zoneinfo import ZoneInfo
dt = datetime(2026, 6, 30, 14, 30, tzinfo=ZoneInfo("America/New_York"))
print(dt.strftime("%Y-%m-%d %H:%M %Z%z")) # 2026-06-30 14:30 EDT-0400
print(dt.strftime("%A, %B %d, %Y")) # Tuesday, June 30, 2026
Handy directives: %Y four-digit year, %m month, %d day, %H/%M/%S time, %A/%B weekday/month names, %Z zone abbreviation, %z numeric offset. For machine-readable output, prefer isoformat() over hand-rolled strftime patterns — it's unambiguous and parses straight back.
Arithmetic with timedelta
Adding or subtracting durations uses timedelta, and subtracting two datetimes gives you one back:
from datetime import datetime, timedelta
start = datetime(2026, 6, 30, 14, 30)
later = start + timedelta(days=2, hours=3)
print(later) # 2026-07-02 17:30:00
span = datetime(2026, 7, 1) - datetime(2026, 6, 30)
print(span.days) # 1
print(span.total_seconds()) # 86400.0
Use total_seconds() when you need the whole duration as a number — .seconds alone only holds the sub-day remainder and is a frequent source of bugs.
Unix timestamps
A Unix timestamp is seconds since 1970-01-01 UTC. Convert with timestamp() and fromtimestamp(). Always pass an explicit tz when converting back, so you get an aware value rather than relying on the machine's local zone:
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
dt = datetime(2026, 6, 30, 14, 30, tzinfo=ZoneInfo("America/New_York"))
ts = dt.timestamp()
print(ts) # 1782844200.0
back = datetime.fromtimestamp(ts, tz=timezone.utc)
print(back.isoformat()) # 2026-06-30T18:30:00+00:00
The daylight-saving traps
This is where naive code breaks. When clocks spring forward, a wall-clock hour doesn't exist; when they fall back, an hour happens twice. Two scenarios are worth internalizing.
Wall-clock arithmetic is not elapsed time
Adding 24 hours to an aware datetime advances the wall clock by a day, but the actual elapsed time can differ across a DST boundary:
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
ny = ZoneInfo("America/New_York")
start = datetime(2026, 3, 7, 12, 0, tzinfo=ny) # day before spring-forward
later = start + timedelta(hours=24)
print(later.isoformat()) # 2026-03-08T12:00:00-04:00 (offset changed!)
elapsed = later.astimezone(ZoneInfo("UTC")) - start.astimezone(ZoneInfo("UTC"))
print(elapsed) # 23:00:00 -> only 23 real hours passed
If you need a true 24-hour interval, do the math in UTC; if you need "same time tomorrow," do it in the local zone. They are genuinely different questions, and knowing which one you're asking is the whole game.
Ambiguous times and the fold attribute
During a fall-back transition the same wall time occurs twice. The fold attribute (0 for the first occurrence, 1 for the second) disambiguates which one you mean:
from datetime import datetime
from zoneinfo import ZoneInfo
ny = ZoneInfo("America/New_York")
first = datetime(2026, 11, 1, 1, 30, fold=0, tzinfo=ny)
second = datetime(2026, 11, 1, 1, 30, fold=1, tzinfo=ny)
print(first.utcoffset()) # -1 day, 20:00:00 -> EDT (-4)
print(second.utcoffset()) # -1 day, 19:00:00 -> EST (-5)
You rarely set fold by hand, but understanding it explains why two seemingly identical timestamps can map to different instants — exactly the kind of thing that produces duplicate or missing records around the transition.
Practical tips and common pitfalls
A few habits prevent most date-time bugs. Store and compute in UTC, converting to local zones only for display. Keep datetimes aware end to end; treat a naive value crossing a boundary as a bug. Use IANA zone names rather than fixed offsets so DST is handled for you. Prefer isoformat()/fromisoformat() for serialization. And never assume timedelta(days=1) equals 24 real hours in a zone that observes daylight saving — it usually does, but the exceptions are precisely the moments that cause incidents.
One more gotcha: avoid the bare datetime.utcnow() (it returns a naive value that merely looks like UTC, which is misleading and discouraged in modern Python). Use datetime.now(timezone.utc) instead — it's explicit and aware.
Wrap-up and next steps
The mental model that makes datetime click is simple: an aware datetime is a precise instant; a naive one is just numbers on a clock face. Attach a zone with zoneinfo, keep your storage in UTC, convert with astimezone(), and reach for fold and UTC-based math when daylight saving is in play.
From here, explore the calendar module for higher-level date logic, look at dateutil for flexible parsing and recurrence rules when the standard library isn't enough, and if you work with pandas, study its timezone-aware Timestamp type, which builds on these same concepts. Get the fundamentals here right and an entire category of production bugs simply disappears.