Python's datetime module is the standard way to represent points in time and durations. The main classes are date (year/month/day), time (hour/minute/second), datetime (both combined), and timedelta (a span between two instants). Pick the smallest class that covers your need — a birthday is a date, a reminder is a datetime, a countdown is a timedelta.
The single most important distinction is aware vs naive. An aware datetime carries a tzinfo (a timezone); a naive one does not. Mixing them in arithmetic raises TypeError. Real-world software should be aware by default: use datetime.now(timezone.utc) for internal timestamps and convert to local time only for display. Storing naive datetimes “in UTC” is a subtle bug waiting to happen.
Parsing and formatting are where most date code happens. datetime.fromisoformat("2025-01-31T12:30:00+00:00") reads ISO 8601, the default machine-friendly format. dt.strftime("%Y-%m-%d") formats with codes (%Y year, %m month, %d day, %H hour, %M minute, %S second); strptime is its inverse. Prefer ISO format for anything not displayed to users.
Arithmetic on datetimes is expressed in timedelta. dt + timedelta(days=7) moves a week forward; dt2 - dt1 returns a timedelta. For month or year math you need dateutil.relativedelta, because calendar months don't have a fixed duration. Never add “30 days” when you mean “one month”.
Aware datetimes are the default
Build with datetime.now(timezone.utc) on the way in, convert to local with dt.astimezone(zoneinfo.ZoneInfo("Europe/Oslo")) on the way out. Serialize with dt.isoformat(); parse with datetime.fromisoformat(...).
Python 3.9+ ships zoneinfo, the official time zone database. Before 3.9 you had to install pytz; new code should always use zoneinfo.
Durations and formatting
timedelta(days=D, hours=H, seconds=S) is an ordinary object you can add to datetimes and multiply by integers. Use it to encode intervals explicitly rather than sprinkling magic numbers through the code.
For format strings, cheat sheet: %Y-%m-%d %H:%M:%S is the most common non-ISO timestamp. %a/%A are weekday short/long names; %b/%B are month names; %z is the UTC offset; %Z is the tz name. Always test the formatted strings you plan to parse back in.
The datetime vocabulary.
| Tool | Purpose |
|---|---|
datetime.dateclass | Calendar date with year/month/day. |
datetime.datetimeclass | Date + time (optionally timezone-aware). |
datetime.timedeltaclass | Span between two datetimes. |
zoneinfo.ZoneInfoclass | Named time zone (Python 3.9+). |
datetime.now(tz)method | Current wall-clock time. |
datetime.fromisoformatmethod | Parse ISO 8601 strings. |
strftime/strptimemethods | Format/parse with explicit codes. |
time.monotonic()function | Monotonic clock for measuring intervals. |
Managing Dates and Times code example
The script below creates aware datetimes, does simple arithmetic, formats for display, and measures a short interval.
# Lesson: Managing Dates and Times
import time
from datetime import date, datetime, timedelta, timezone
from zoneinfo import ZoneInfo
utc_now = datetime.now(timezone.utc)
oslo = utc_now.astimezone(ZoneInfo("Europe/Oslo"))
tokyo = utc_now.astimezone(ZoneInfo("Asia/Tokyo"))
print("utc now:", utc_now.isoformat())
print("oslo: ", oslo.strftime("%Y-%m-%d %H:%M %Z"))
print("tokyo: ", tokyo.strftime("%Y-%m-%d %H:%M %Z"))
# Arithmetic with timedelta
one_week = timedelta(days=7)
later = utc_now + one_week
print("in 7 days:", later.isoformat())
print("delta: ", later - utc_now, "|", (later - utc_now).days, "days")
# Parse ISO 8601 round-trip
payload = utc_now.isoformat()
parsed = datetime.fromisoformat(payload)
print("round-trip equal?", parsed == utc_now)
# date-only math
today = date.today()
next_friday = today + timedelta(days=(4 - today.weekday()) % 7)
print("next friday:", next_friday.isoformat())
# Measure an interval with a monotonic clock
start = time.monotonic()
time.sleep(0.05)
elapsed = time.monotonic() - start
print(f"slept for ~{elapsed*1000:.0f} ms")
# Naive + aware mix raises an error, on purpose
naive = datetime(2025, 1, 1)
try:
naive - utc_now
except TypeError as err:
print("mix error:", err)
As you read, focus on:
1) `datetime.now(timezone.utc)` is the aware-by-default habit.
2) `astimezone(ZoneInfo(...))` converts without changing the instant.
3) `fromisoformat` / `isoformat` is the canonical round-trip pair.
4) Mixing naive and aware datetimes raises TypeError — that's a feature.
Practice duration and formatted output.
from datetime import datetime, timedelta, timezone
start = datetime(2025, 1, 1, tzinfo=timezone.utc)
end = start + timedelta(hours=36, minutes=15)
delta = end - start
total_minutes = int(delta.total_seconds() // 60)
print(f"{total_minutes} minutes")
fmt = end.strftime("%A %B %d %Y, %H:%M %Z")
print(fmt)
Invariants to lean on.
from datetime import datetime, timedelta, timezone
t = datetime(2025, 1, 1, tzinfo=timezone.utc)
assert (t + timedelta(days=1)).day == 2
assert t.isoformat() == "2025-01-01T00:00:00+00:00"
assert datetime.fromisoformat(t.isoformat()) == t
Running prints something like:
utc now: 2026-04-21T10:00:00+00:00
oslo: 2026-04-21 12:00 CEST
tokyo: 2026-04-21 19:00 JST
in 7 days: 2026-04-28T10:00:00+00:00
delta: 7 days, 0:00:00 | 7 days
round-trip equal? True
next friday: 2026-04-24
slept for ~51 ms
mix error: can't subtract offset-naive and offset-aware datetimes