Dates and times
datetime, timedelta, and zoneinfo — the right way to handle dates so you stop shipping bugs at midnight UTC.
What you'll learn
- datetime, date, time — what each represents
- Naive vs aware datetimes — the source of half of all date bugs
- Parsing with fromisoformat and strptime, formatting with strftime
- timedelta for date math, and zoneinfo for time zones
Before you start
If you have shipped one bug involving dates and times, you have very likely shipped three. The encouraging part is that almost all of them reduce to a single mistake: mixing a naive datetime (no time zone) with an aware one, or assuming UTC where the input was really local time. Get that one distinction right, store everything in UTC, and the whole category of midnight-and-daylight-saving bugs mostly evaporates. Let us build up to that rule deliberately.
The four types
from datetime import date, time, datetime, timedelta
date(2026, 5, 27) # just a calendar date — no time, no tz
time(14, 30, 0) # just a clock time — no date, no tz
datetime(2026, 5, 27, 14, 30) # both together
timedelta(days=7, hours=3) # a DURATION — for date math
datetime is the one you will reach for almost every time; the standalone date and time exist for the cases where you genuinely have only half the picture. You build one directly and read its parts as attributes:
from datetime import date, datetime
d = date(2026, 5, 27)
dt = datetime(2026, 5, 27, 14, 30, 0)
print("date: ", d)
print("datetime:", dt)
print("year: ", dt.year, "month:", dt.month, "day:", dt.day)
print("weekday: ", dt.weekday(), "(0=Mon)")
date: 2026-05-27
datetime: 2026-05-27 14:30:00
year: 2026 month: 5 day: 27
weekday: 2 (0=Mon)
(weekday() numbers the days from Monday at 0, so 2 is a Wednesday.) For the current moment there is datetime.now() and date.today() — but pause before you use them, because that is exactly where the most important trap lives.
The naive vs aware trap
A naive datetime carries no time-zone information: Python sees the wall-clock numbers but has no idea which actual instant on Earth they refer to. An aware datetime has a tzinfo attached, so it pins down a real, global moment. Mixing the two silently is behind an astonishing number of production bugs.
from datetime import datetime, timezone
naive = datetime(2026, 5, 27, 14, 30)
aware = datetime(2026, 5, 27, 14, 30, tzinfo=timezone.utc)
print("naive: ", naive, "tzinfo:", naive.tzinfo)
print("aware: ", aware, "tzinfo:", aware.tzinfo)
# Python refuses to compare a naive and an aware datetime.
try:
print(naive < aware)
except TypeError as e:
print("error:", e)
naive: 2026-05-27 14:30:00 tzinfo: None
aware: 2026-05-27 14:30:00+00:00 tzinfo: UTC
error: can't compare offset-naive and offset-aware datetimes
Comparison at least fails loudly. The genuinely dangerous case is silent arithmetic — now - signup_time — which can be wrong by hours if one side was UTC and the other was the server’s local clock, with no error at all. UTC (Coordinated Universal Time) is the universal reference clock with no daylight-saving shifts, which makes it the safe common currency for storing times. The rule that follows is the most valuable sentence in this lesson: make every datetime aware, store it in UTC, and convert to local time only for display.
zoneinfo — the modern time-zone API
Python 3.9 added zoneinfo, which reads time-zone rules from the operating system’s IANA database. Before 3.9 you needed the third-party pytz; today zoneinfo is the default. Its key method, astimezone, converts an aware datetime between zones without changing the underlying instant — only how it is displayed:
from datetime import datetime
from zoneinfo import ZoneInfo
# One aware instant, in UTC.
utc = datetime(2026, 5, 27, 14, 30, tzinfo=ZoneInfo("UTC"))
print("UTC: ", utc)
# The SAME instant, shown on three different wall-clocks.
print("Kolkata: ", utc.astimezone(ZoneInfo("Asia/Kolkata")))
print("New York: ", utc.astimezone(ZoneInfo("America/New_York")))
print("Tokyo: ", utc.astimezone(ZoneInfo("Asia/Tokyo")))
UTC: 2026-05-27 14:30:00+00:00
Kolkata: 2026-05-27 20:00:00+05:30
New York: 2026-05-27 10:30:00-04:00
Tokyo: 2026-05-27 23:30:00+09:00
astimezone changes the wall-clock you display, never the instant underneath — all four lines are the same moment.Parsing — fromisoformat and strptime
Dates arrive as strings, from APIs and CSVs and logs, and you have two ways to parse them. When the input follows ISO 8601 — YYYY-MM-DD for dates, YYYY-MM-DDTHH:MM:SS±HH:MM for datetimes — reach for fromisoformat, which is fast and forgiving:
from datetime import datetime
print(datetime.fromisoformat("2026-05-27"))
print(datetime.fromisoformat("2026-05-27T14:30:00"))
print(datetime.fromisoformat("2026-05-27T14:30:00+05:30"))
print(datetime.fromisoformat("2026-05-27T14:30:00Z")) # Python 3.11+ accepts Z
2026-05-27 00:00:00
2026-05-27 14:30:00
2026-05-27 14:30:00+05:30
2026-05-27 14:30:00+00:00
If you are designing an API, choose ISO 8601: it sorts correctly as plain text ('2026-05-27' < '2026-05-28'), parses in one line, and removes the eternal ambiguity of 05/06/2026 — May 6th or June 5th? For non-ISO formats, strptime takes a format string of placeholders:
from datetime import datetime
# RFC-style, with weekday and month name.
print(datetime.strptime("Wed, 27 May 2026 14:30:00", "%a, %d %b %Y %H:%M:%S"))
# US-style M/D/Y with a 12-hour clock.
print(datetime.strptime("05/27/2026 02:30 PM", "%m/%d/%Y %I:%M %p"))
# A common log format.
print(datetime.strptime("2026-05-27 14:30:12", "%Y-%m-%d %H:%M:%S"))
2026-05-27 14:30:00
2026-05-27 14:30:00
2026-05-27 14:30:12
The format codes worth knowing by heart: %Y (4-digit year), %m (month), %d (day), %H (24-hour) and %M/%S, plus %I/%p for 12-hour-with-AM/PM, %a/%b for weekday and month names, and %z for a numeric UTC offset like +0530.
Formatting and date math
strftime is the inverse of strptime, using the same codes, though for ISO output .isoformat() is cleaner and harder to get wrong:
dt = datetime(2026, 5, 27, 14, 30)
dt.strftime("%Y-%m-%d") # '2026-05-27'
dt.strftime("%B %d, %Y") # 'May 27, 2026'
dt.isoformat() # '2026-05-27T14:30:00'
And timedelta does the arithmetic — add it, subtract it, and subtract two datetimes to get one back:
from datetime import datetime, timedelta
dt = datetime(2026, 5, 27, 14, 30)
later = dt + timedelta(days=7)
earlier = dt - timedelta(hours=12, minutes=30)
delta = later - earlier
print(delta.days, delta.total_seconds())
7 649800.0
One sharp edge: timedelta accepts days, seconds, minutes, hours, weeks — but no months or years, because those are variable in length. For “one month from now” you need python-dateutil’s relativedelta.
A real example — 7-day retention
Bring it together on a task you will actually meet: given users with signup times and a stream of activity events, which users were active between one and seven days after signing up? Every datetime is stored UTC-aware, and the whole test is a single subtraction:
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
UTC = ZoneInfo("UTC")
users = [
{"id": 1, "signup": datetime(2026, 5, 1, 10, 0, tzinfo=UTC)},
{"id": 2, "signup": datetime(2026, 5, 5, 16, 0, tzinfo=UTC)},
{"id": 3, "signup": datetime(2026, 5, 20, 8, 0, tzinfo=UTC)},
]
activity = [
{"user_id": 1, "at": datetime(2026, 5, 3, 9, 0, tzinfo=UTC)}, # ~2 days after
{"user_id": 1, "at": datetime(2026, 5, 9, 9, 0, tzinfo=UTC)}, # ~8 days — too late
{"user_id": 2, "at": datetime(2026, 5, 8, 9, 0, tzinfo=UTC)}, # ~3 days after
{"user_id": 3, "at": datetime(2026, 5, 21, 9, 0, tzinfo=UTC)}, # ~1 day after
]
signups = {u["id"]: u["signup"] for u in users}
retained = set()
for e in activity:
age = e["at"] - signups[e["user_id"]]
if timedelta(days=1) <= age <= timedelta(days=7):
retained.add(e["user_id"])
print("retained users:", sorted(retained))
print(f"day-7 retention: {len(retained)}/{len(users)} = {len(retained) / len(users):.0%}")
retained users: [1, 2, 3]
day-7 retention: 3/3 = 100%
User 1’s late event (eight days out) was correctly ignored, while their two-day event counted. Because every value is aware and in UTC, the window check is just timedelta(days=1) <= age <= timedelta(days=7) — no time-zone reasoning, no off-by-hours surprises. Keep everything in UTC, do the math with timedelta, and convert to a local zone only when you finally display a result.
In one breath
datetimeis the workhorse;date/timeare the half-pictures;timedeltais a duration.- A naive datetime has
tzinfo = None; an aware one pins a real instant — never mix them. - Store UTC, compute in UTC, convert with
astimezoneonly at the edges. - Parse ISO 8601 with
fromisoformat, other formats withstrptime; format with.isoformat()orstrftime. timedeltahas no months or years — reach fordateutil.relativedeltawhen you need them.
Practice
Quick check
What’s next
You have parsed structured data out of strings and timestamps. Next is logging — the right way to produce structured, level-aware logs in the first place, so the next person never has to write a regex to read them.
Practice this in an interview
All questionsConvert string columns to datetime with pd.to_datetime(), then use the .dt accessor to extract components like year, month, day, and day of week, compute time deltas, and perform resampling. Setting a DatetimeIndex unlocks time-series-specific operations like resample, rolling, and time-aware interpolation.
Raw timestamps are meaningless to most models. Useful features extracted from a datetime column include calendar components (hour, day of week, month, quarter, year), cyclical encodings of periodic components (sin/cos of hour or day-of-week), lag and rolling-window aggregates, time-since-event features, and business-calendar flags like is_weekend or is_holiday.