Dates, Times, and Timezones
1 · The lesson
readEvery non-trivial program eventually deals with time — log entries, schedules, expiry, billing periods, "5 minutes ago" labels. The datetime module gives you the primitives. The danger isn't the API; it's timezones. A datetime without a timezone is a number without a unit — looks fine until two systems disagree about what it means.
This lesson covers the four types, the naive-vs-aware distinction that causes most timezone bugs, parsing/formatting with strftime/strptime, the modern zoneinfo module, and timedelta arithmetic. By the end, "store UTC, display local" will feel like the only sensible rule.
1. The Four Types
from datetime import date, time, datetime, timedelta d = date(2026, 5, 14) # year-month-day, no time t = time(9, 30, 0) # hour-minute-second, no date dt = datetime(2026, 5, 14, 9, 30) # both td = timedelta(days=7, hours=3) # a *duration*, not a moment print(d, t, dt, td)
date— a calendar day. Use this when "the time of day" is meaningless (birthdays, due dates, holidays).time— a clock reading without a date. Rare on its own; mostly appears asdt.time().datetime— a specific moment. The workhorse.timedelta— a span between two moments. The result of subtracting two datetimes.
Pick the smallest type that fits. A date can't be off-by-an-hour because of DST — a datetime can.
2. "Now" — and the One You Should Use
from datetime import datetime, timezone # Three ways to get "now". Only one is safe. a = datetime.now() # naive — no timezone attached b = datetime.utcnow() # naive — deprecated in 3.12 c = datetime.now(tz=timezone.utc) # aware — explicit UTC. Use this. print(a) # 2026-05-14 09:30:00.123 print(c) # 2026-05-14 09:30:00.123+00:00 print(c.tzinfo) # datetime.timezone.utc
datetime.utcnow() is a trap — it returns a naive datetime that happens to contain UTC values, with no timezone tag. Pass it into anything timezone-aware and you'll get wrong answers silently. Python 3.12 deprecated it for exactly this reason.
Always use datetime.now(tz=timezone.utc) for "right now" in code that will outlive a single machine.
3. Naive vs Aware — The Source of Most Bugs
A datetime is aware if it has a tzinfo attached, naive otherwise. Mix them and Python refuses:
from datetime import datetime, timezone naive = datetime(2026, 5, 14, 9, 30) aware = datetime(2026, 5, 14, 9, 30, tzinfo=timezone.utc) print(aware - aware) # 0:00:00 — fine # print(aware - naive) # TypeError: can't subtract offset-naive from offset-aware
The rule: decide at the system boundary, then stay aware everywhere internally. Anything that crosses a network, a database, or another process should be timezone-aware. The minute you let a naive datetime escape into a shared system, somebody three time zones away will see the wrong hour.
4. Named Timezones with zoneinfo
Since Python 3.9, the stdlib ships the IANA timezone database via zoneinfo. No third-party pytz needed.
from datetime import datetime from zoneinfo import ZoneInfo india = ZoneInfo("Asia/Kolkata") launch = datetime(2026, 5, 14, 9, 30, tzinfo=india) print(launch) # 2026-05-14 09:30:00+05:30 print(launch.tzinfo) # Asia/Kolkata
Convert between zones with astimezone — Python does the maths:
from zoneinfo import ZoneInfo launch_in_india = datetime(2026, 5, 14, 9, 30, tzinfo=ZoneInfo("Asia/Kolkata")) launch_in_utc = launch_in_india.astimezone(ZoneInfo("UTC")) launch_in_nyc = launch_in_india.astimezone(ZoneInfo("America/New_York")) print(launch_in_utc) # 2026-05-14 04:00:00+00:00 print(launch_in_nyc) # 2026-05-14 00:00:00-04:00
setup added so this can run · defines datetime
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def datetime(*_a, **_kw): print('-> datetime() called') return _AutoMock('datetime()')
The instant in time is the same — only the wall-clock representation changes. zoneinfo knows about DST too, so "America/New_York" gives -04:00 in summer and -05:00 in winter automatically.
5. Parsing — strptime
strptime ("string parse time") turns a string into a datetime using a format string:
from datetime import datetime dt = datetime.strptime("2026-05-14 09:30:00", "%Y-%m-%d %H:%M:%S") print(dt) # 2026-05-14 09:30:00 print(type(dt)) # <class 'datetime.datetime'>
The six format codes you'll use 90% of the time:
| Code | Meaning | Example |
|---|---|---|
%Y | 4-digit year | 2026 |
%m | 2-digit month | 05 |
%d | 2-digit day | 14 |
%H | 2-digit hour 0-23 | 09 |
%M | 2-digit minute | 30 |
%S | 2-digit second | 00 |
Two more worth knowing:
| Code | Meaning | Example |
|---|---|---|
%z | UTC offset | +0530 |
%A | Full weekday name | Thursday |
dt = datetime.strptime("2026-05-14 09:30 +0530", "%Y-%m-%d %H:%M %z") print(dt.tzinfo) # UTC+05:30
setup added so this can run · defines datetime
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) datetime = _AutoMock('datetime')
6. Formatting — strftime and isoformat
The reverse trip: datetime → string.
from datetime import datetime, timezone now = datetime.now(tz=timezone.utc) print(now.strftime("%Y-%m-%d %H:%M:%S")) # 2026-05-14 09:30:00 print(now.strftime("%A, %d %B %Y")) # Thursday, 14 May 2026 print(now.isoformat()) # 2026-05-14T09:30:00.123456+00:00
For storage, prefer isoformat() — ISO 8601 is unambiguous, sortable as a string, and parseable by every language in existence. strftime is for display.
The parsing counterpart is even better:
from datetime import datetime dt = datetime.fromisoformat("2026-05-14T09:30:00+00:00") print(dt) # 2026-05-14 09:30:00+00:00
fromisoformat is much faster than strptime and needs no format string. If you control both sides of a serialization boundary, use ISO 8601 always.
7. timedelta Arithmetic
Datetimes minus datetimes give timedeltas. Datetimes plus timedeltas give datetimes. That's the whole calculus:
from datetime import datetime, timedelta, timezone now = datetime.now(tz=timezone.utc) week_from_now = now + timedelta(days=7) two_hours_ago = now - timedelta(hours=2) duration = week_from_now - now # timedelta(days=7) print(duration.total_seconds()) # 604800.0 print(duration.days) # 7
timedelta accepts weeks, days, hours, minutes, seconds, milliseconds, microseconds. No months or years — those aren't fixed durations (a "month" is anywhere from 28 to 31 days). For calendar-aware additions, reach for dateutil.relativedelta:
# pip install python-dateutil from dateutil.relativedelta import relativedelta from datetime import date print(date(2026, 1, 31) + relativedelta(months=1)) # 2026-02-28 — handles short months
8. Measuring Elapsed Time
Don't use time.time() to measure how long something took — it's the wall clock, which jumps when NTP corrects it. Use time.perf_counter():
import time start = time.perf_counter() sum(range(1_000_000)) # do the work elapsed = time.perf_counter() - start print(f"{elapsed:.4f}s") # ~0.0150s
time.sleep(seconds) pauses execution — handy for polling intervals or rate limiting. Both functions ship with the stdlib; no install needed.
9. Best Practices
- Store UTC. Display local. Persist datetimes in UTC (or as ISO 8601 with offset); convert with
astimezone(ZoneInfo(user_tz))only when rendering for a human. - Be explicit about timezone. Every aware datetime is safer than every "I'm pretty sure this is UTC" datetime.
- Date-only operations use
date, notdatetime. Birthdays don't have timezones. - ISO 8601 for serialization.
isoformat()out,fromisoformat()in. pendulumandarroware popular third-party libraries with friendlier APIs. The modern stdlib (datetime+zoneinfo) is enough for 95% of cases — reach for them only if you're doing heavy duration arithmetic.
Common Mistakes
- Naive
datetime.now()passed to a timezone-aware system. Database stores it as if it's UTC; users in other zones see times shifted by hours. Always tag withtz=timezone.utc. - Subtracting a naive from an aware datetime.
TypeError. Make both aware, or both naive — never mix. - Storing local time in a database. DST jumps break ordering (an hour disappears in spring, repeats in autumn). Always store UTC.
- Using
time.time()for measurements. Wall clock; jumps with NTP corrections. Usetime.perf_counter(). - Confusing
%mand%M.%mis month,%Mis minute. The number of times this bites people is embarrassing. - Adding "30 days" to mean "one month". Months are variable. Use
dateutil.relativedelta(months=1)for true calendar arithmetic.
🎯 Your Turn — Business Days Between Two Dates
Write business_days_between(start, end) that returns the count of weekdays (Mon-Fri) between two date objects, exclusive of the start, inclusive of the end. Holidays don't count — just weekends.
Use date, not datetime — this is calendar arithmetic, no clocks involved.
from datetime import date, timedelta def business_days_between(start, end): """Return the number of weekdays (Mon-Fri) strictly after `start` and up to & including `end`. If end <= start, return 0. """ # TODO 1: handle the empty/reversed case # TODO 2: iterate from start+1 day to end, counting weekdays # TODO 3: weekdays have weekday() in 0..4 (Mon=0, Sun=6) pass # Tests print(business_days_between(date(2026, 5, 11), date(2026, 5, 15))) # 4 (Tue, Wed, Thu, Fri) print(business_days_between(date(2026, 5, 15), date(2026, 5, 18))) # 1 (Mon — skips Sat/Sun) print(business_days_between(date(2026, 5, 14), date(2026, 5, 14))) # 0 (same day) print(business_days_between(date(2026, 5, 20), date(2026, 5, 14))) # 0 (reversed)
Hint 1 — Iterating days
You can't iterate adate directly, but you can step with timedelta(days=1). Start at start + timedelta(days=1) and increment until you pass end.
Hint 2 — Weekdays only
d.weekday() returns 0 for Monday through 6 for Sunday. Saturday is 5, Sunday is 6. A weekday satisfies d.weekday() < 5.
Show full solution
from datetime import date, timedelta def business_days_between(start, end): if end <= start: return 0 count = 0 current = start + timedelta(days=1) while current <= end: if current.weekday() < 5: # Mon-Fri count += 1 current += timedelta(days=1) return count print(business_days_between(date(2026, 5, 11), date(2026, 5, 15))) # 4 print(business_days_between(date(2026, 5, 15), date(2026, 5, 18))) # 1 print(business_days_between(date(2026, 5, 14), date(2026, 5, 14))) # 0 print(business_days_between(date(2026, 5, 20), date(2026, 5, 14))) # 0
A faster O(1) version exists using divmod on the day count, but the loop is clear and fast enough for typical ranges (a year is 365 iterations — microseconds).
What You Learned
- The four types:
date(calendar day),time(clock reading),datetime(moment),timedelta(span). - Naive vs aware: aware has a
tzinfo, naive doesn't. Mix them and Python raisesTypeError. - Use
datetime.now(tz=timezone.utc)— never baredatetime.now()or the deprecatedutcnow(). zoneinfo.ZoneInfo("Area/City")for named timezones;astimezone()to convert.strptime/strftimefor human formats;fromisoformat/isoformatfor storage. ISO 8601 wins.timedeltaarithmetic withdays/hours/minutes/seconds— no months or years (userelativedelta).time.perf_counter()for measurements, nevertime.time().- Store UTC, display local. Repeat as needed.
Next: collections — Counter, defaultdict, deque, and the stdlib power tools you'll reach for in every project.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code actually did, not just whether a test passed.