datarekha

Dates and times

datetime, timedelta, and zoneinfo — the right way to handle dates so you stop shipping bugs at midnight UTC.

10 min read Intermediate Python Lesson 23 of 41

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
14:30 UTCone real instantKolkata 20:00 (+05:30)New York 10:30 (-04:00)Tokyo 23:30 (+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 8601YYYY-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

  • datetime is the workhorse; date/time are the half-pictures; timedelta is 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 astimezone only at the edges.
  • Parse ISO 8601 with fromisoformat, other formats with strptime; format with .isoformat() or strftime.
  • timedelta has no months or years — reach for dateutil.relativedelta when you need them.

Practice

Quick check

0/3
Q1What is a 'naive' datetime?
Q2Why store all datetimes in UTC?
Q3When would you use `fromisoformat` instead of `strptime`?

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.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions

Related lessons

Explore further

Cheat sheets
Skip to content