Python datetime, explained
Dates and times are deceptively tricky — timezones, parsing, and arithmetic all have sharp
edges. Python's datetime module has everything you need, but using it well means
understanding a few key distinctions, above all naive vs timezone-aware datetimes.
The core types
The module provides date (calendar day), time (clock time), datetime (both together),
and timedelta (a duration). datetime is the one you'll use most.
from datetime import date, datetime, time, timedelta
date(2026, 6, 19) # 2026-06-19
datetime(2026, 6, 19, 14, 30) # 2026-06-19 14:30:00
datetime.now() # current local datetime (naive!)
date.today() # today's date
datetime.now() returns a naive datetime by default — no timezone attached, which is the
source of most datetime bugs.
Naive vs timezone-aware
A naive datetime has no timezone; an aware one carries a tzinfo. Mixing them, or
assuming a naive value is UTC (or local), causes silent errors. Make production datetimes
aware and prefer UTC.
from datetime import datetime, timezone
datetime.now() # naive — ambiguous which zone
datetime.now(timezone.utc) # aware, UTC — do this
datetime.now().tzinfo # None
datetime.now(timezone.utc).tzinfo # UTC
Rule of thumb: store and compute in UTC, convert to local only for display. You can't
subtract a naive from an aware datetime — Python raises a TypeError.
Timezones with zoneinfo
Since Python 3.9, the standard library's zoneinfo provides real IANA timezones — no
third-party pytz needed. Use it to convert between zones.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
utc_now = datetime.now(timezone.utc)
ny = utc_now.astimezone(ZoneInfo("America/New_York")) # convert zone
tokyo = utc_now.astimezone(ZoneInfo("Asia/Tokyo"))
astimezone converts an aware datetime to another zone correctly, handling DST.
Arithmetic with timedelta
Subtracting two datetimes gives a timedelta; adding a timedelta shifts a datetime.
Durations are easy and exact.
from datetime import datetime, timedelta
start = datetime(2026, 6, 19, 9)
end = datetime(2026, 6, 19, 17, 30)
duration = end - start # timedelta(hours=8, minutes=30)
duration.total_seconds() # 30600.0
deadline = datetime.now() + timedelta(days=7, hours=12)
total_seconds() is the right way to get a duration as a single number; don't try to read
.seconds alone (it excludes the days component).
Formatting and parsing
strftime formats a datetime to a string; strptime parses a string into a datetime, using
the same directive codes (%Y, %m, %d, %H, %M).
dt = datetime(2026, 6, 19, 14, 30)
dt.strftime("%Y-%m-%d %H:%M") # '2026-06-19 14:30'
dt.strftime("%A, %B %d") # 'Friday, June 19'
datetime.strptime("2026-06-19", "%Y-%m-%d") # parse back to datetime
For the ISO 8601 standard format, prefer dt.isoformat() and datetime.fromisoformat() —
no format string needed.
Timestamps and the epoch
Convert to and from Unix timestamps (seconds since 1970-01-01 UTC) for storage or APIs.
from datetime import datetime, timezone
ts = datetime.now(timezone.utc).timestamp() # float seconds
datetime.fromtimestamp(ts, tz=timezone.utc) # back to aware datetime
Always pass tz=timezone.utc to fromtimestamp to get an aware result rather than a
local-time naive one.
Recap
Use datetime for date+time, timedelta for durations, and date/time for the
parts. The crucial distinction is naive vs aware: datetime.now() is naive — prefer
datetime.now(timezone.utc), store/compute in UTC, and convert with astimezone +
zoneinfo only for display. Do arithmetic with timedelta and total_seconds(). Format
and parse with strftime/strptime directives, or use isoformat/fromisoformat for
ISO 8601. Pass tz= when converting timestamps so results stay aware.