Dates, Times, and Time Zones
Naive versus aware datetimes, storing UTC and displaying local, zoneinfo, and the arithmetic that goes wrong twice a year when clocks change.
Naive versus aware datetimes, storing UTC and displaying local, zoneinfo, and the arithmetic that goes wrong twice a year when clocks change.
A report that runs at midnight has been correct for seven months. On the last Sunday in October it processes an hour of data twice, and on the last Sunday in March it misses an hour entirely. Nobody notices until an annual reconciliation is out by two hours' worth of transactions.
Time looks like the simplest possible field and is the one most likely to be quietly wrong. The reason is that "now" is not a single thing, and the moment you store one without saying which one, the information needed to interpret it is gone. By the end of this lesson you will know why, and you will have a discipline that makes the whole category of bug hard to write.
Python has two kinds of datetime, and the difference is
whether it knows its own offset from UTC.
from datetime import datetime, timezone
naive = datetime.now() # no timezone attached
aware = datetime.now(timezone.utc) # knows it is UTC
print(naive.tzinfo) # None
print(aware.tzinfo) # UTCA wall-clock reading, from an unknown wall.
2026-10-25 02:30 could be two different instants an hour
apart, and nothing in the value tells you which.
It cannot be compared, converted, or stored without losing information you did not know you had.
An actual instant.
Comparable with any other aware datetime, convertible to any zone, and storable without loss.
Mixing them raises, which is one of Python's better decisions:
aware - naive
# TypeError: can't subtract offset-naive and offset-aware datetimesThat error is not an obstacle. It is the type system catching a comparison that had no meaning.
Bad — recording local wall-clock time.
photo.uploaded_at = datetime.now()Good — recording an instant.
photo.uploaded_at = datetime.now(timezone.utc)The first version writes whatever the clock on that machine said, with nothing recording the zone. It works on your laptop, where you and the data share a timezone. It breaks when the code also runs on a server set to UTC, because now the same column holds two kinds of value and no query can tell them apart. And it breaks twice a year at home, when the local clock repeats an hour — two rows with identical timestamps, an hour apart, and no way to order them.
The discipline in one line: store UTC, convert for display, and never let a naive datetime past your boundary.
zoneinfo is in the standard library and reads the system
timezone database. On Windows, which has no such database,
install tzdata.
A common half-fix is to store an offset — +01:00 — and think
the job is done. It is not.
A number of hours from UTC.
Knowing an event was at +01:00 does not tell you it was in
Lisbon, and it does not let you work out what the local clock
will say next March.
Rules about which offset applies when.
Including when it changes, and the political decisions that alter those rules — sometimes at a few weeks' notice.
So the rule extends. For a past instant, UTC is enough. For a future event that must happen at a local wall-clock time — an alarm at 9am, a report at midnight — store the zone name and the local time, because the offset for that date is not yet knowable.
When clocks go back, a local hour repeats. When they go forward, an hour does not exist.
fold distinguishes the two occurrences, and it is easy to miss
because both values print identically. The reliable answer is
not to be there at all: do the arithmetic in UTC, where every
hour happens once.
Adding a timedelta to an aware local datetime does absolute
arithmetic, not calendar arithmetic — so "the same time
tomorrow" is not + timedelta(days=1) across a transition. If
you mean the wall clock, convert to local, replace the date, and
convert back.
ISO 8601 is the format to use everywhere — it sorts correctly as text, it is unambiguous, and Python reads and writes it directly:
For anything else, strptime parses and strftime formats:
That last line is the trap. Parsing a string without an offset produces a naive datetime, so this is exactly where naive values sneak into a codebase that otherwise stays aware. Attach the zone you know it means, immediately:
Use replace when the value was always in that zone and merely
failed to say so. Use astimezone to convert an already-aware
value to a different zone. Confusing the two shifts your data by
the offset, silently.
timedelta is exact arithmetic — seconds, essentially:
.seconds is the seconds component, so a two-day duration
reports a small number. .total_seconds() is nearly always what
you want.
What timedelta cannot do is calendar arithmetic. "One month
later" has no fixed length, and "the 31st, next month" does not
always exist. For that, use dateutil:
And when the time of day is irrelevant, use a date rather than
a datetime at midnight. A date cannot be in the wrong
timezone, which removes the problem instead of solving it —
birthdays, invoice dates and public holidays are dates.
The mocking lesson's advice applies precisely here: do not patch the clock, take it as an argument.
No patching, no frozen clock library, and the test can cheaply cover a leap day and a DST transition — which are the cases a patched clock usually never sees.
You now have a discipline rather than a set of tricks: store
UTC, convert at the edges, keep naive values out, and use a
date when there is no time. Most timezone bugs are impossible
under those four rules, and the ones that remain — future local
events, DST transitions — you can now name.
Next is Regular Expressions in Moderation, another tool that is powerful, tempting and frequently the wrong answer. Like this lesson, a good part of it is about knowing when not to reach for the thing it teaches.
Before you move on, find every datetime.now() in your code and
look at what happens to each value. Any that gets stored or
compared should be datetime.now(timezone.utc). That single
search is usually the highest-value hour anyone spends in a
codebase that has never thought about this.
THE TWO KINDS
naive datetime.now() no zone; a wall clock reading
aware datetime.now(timezone.utc) an actual instant
mixing them raises - that is the type system helping
THE DISCIPLINE
store UTC
convert only for display
never let a naive datetime past a boundary
stored.astimezone(ZoneInfo("Europe/Lisbon"))
ZONES VS OFFSETS
an offset is a number; a zone is the rules about which applies
ZoneInfo("Europe/Lisbon") yes
timezone(timedelta(hours=1)) only for UTC or a given offset
past instant -> UTC is enough
future local event -> store the ZONE and the local time
TRANSITIONS
one local hour repeats; another does not exist
fold=0 / fold=1 distinguish the repeat, and print identically
do arithmetic in UTC, where every hour happens once
timedelta is absolute, not calendar
PARSING
isoformat() / fromisoformat() preferred, round-trips
strptime returns a NAIVE datetime <- where naive creeps in
.replace(tzinfo=...) it always meant this zone
.astimezone(...) convert to a different zone
confusing them shifts your data silently
DURATIONS
(end - start).total_seconds() not .seconds
relativedelta(months=1) calendar arithmetic
use a plain date when the time is irrelevant
time.perf_counter() for elapsed time, not the wall clock
TESTING
take `now` as an argument; never patch the clockfrom zoneinfo import ZoneInfo
stored = datetime.now(timezone.utc) # store this
shown = stored.astimezone(ZoneInfo("Europe/Lisbon")) # show this# a past event: an instant
uploaded_at = datetime.now(timezone.utc)
# a future appointment: local time plus the zone it means
appointment = datetime(2027, 3, 28, 9, 0, tzinfo=ZoneInfo("Europe/Lisbon"))lisbon = ZoneInfo("Europe/Lisbon")
# 01:30 happens twice on this date
first = datetime(2026, 10, 25, 1, 30, tzinfo=lisbon, fold=0)
second = datetime(2026, 10, 25, 1, 30, tzinfo=lisbon, fold=1)
print((second - first)) # 0:00:00 -- naive subtraction lies
print(second.utcoffset() - first.utcoffset()) # -1:00:00start = local_start.astimezone(timezone.utc)
end = start + timedelta(hours=1) # unambiguoustext = moment.isoformat() # 2026-07-29T14:30:00+00:00
back = datetime.fromisoformat(text) # aware, round-trips exactlytaken = datetime.strptime("29/07/2026 14:30", "%d/%m/%Y %H:%M")
print(taken.tzinfo) # None - strptime gives a NAIVE datetimetaken = taken.replace(tzinfo=ZoneInfo("Europe/Lisbon"))elapsed = end - start
print(elapsed.total_seconds()) # not .seconds, which drops daysfrom dateutil.relativedelta import relativedelta
next_month = today + relativedelta(months=1) # handles month endsstart = time.perf_counter()
do_the_work()
elapsed = time.perf_counter() - startdef is_expired(photo: Photo, *, now: datetime | None = None) -> bool:
now = now or datetime.now(timezone.utc)
return photo.uploaded_at < now - timedelta(days=30)def test_expires_after_thirty_days():
uploaded = datetime(2026, 1, 1, tzinfo=timezone.utc)
photo = Photo(uploaded_at=uploaded)
assert is_expired(photo, now=uploaded + timedelta(days=31))