Skip to main content

Dates, times & the naive-vs-aware trap

Level 2: Level 2: Idiomsmedium13 mindatetimetimezonestimedeltastrptime

Parse and format timestamps, subtract them safely, and never mix naive with aware.

Two mistakes cause most time bugs

Timestamps look like the simplest data you will ever handle, and they are behind a startling share of production incidents. Two mistakes cause most of them. The first is a format string that does not match the text you were handed, so a batch job either dies halfway through or, worse, reads 03/08 as the eighth of March in one service and the third of August in another. The second is mixing a datetime that knows its offset from UTC with one that does not, which either raises the moment you compare them or quietly reports a duration that is wrong by exactly the size of an offset. Both are cheap to avoid once you know where the seam is.

date is a calendar day, datetime is a day plus a clock

They are separate types and Python will not blur them for you:

from datetime import date, datetime

day = date(2026, 3, 8)                    # 2026-03-08, no time of day
stamp = datetime(2026, 3, 8, 1, 59, 30)   # 2026-03-08 01:59:30
stamp.date()                              # back to a plain date
Check yourself
day = date(2026, 3, 8) and stamp = datetime(2026, 3, 8, 0, 0, 0), midnight on that exact day. What is day == stamp?

Reach for date when the clock genuinely does not matter (a birthday, a billing day) and datetime when it does. Mixing the two in one column is how a report ends up comparing a whole day against one specific second of it.

strptime reads text, strftime writes it

Memorise them by the middle letter: strptime parses a string into a datetime, strftime formats a datetime into a string. Both take the same format codes.

from datetime import datetime

parsed = datetime.strptime("08/03/2026 01:59:30", "%d/%m/%Y %H:%M:%S")
parsed.strftime("%Y-%m-%dT%H:%M:%S")   # '2026-03-08T01:59:30'
parsed.isoformat()                     # same string, no format to mistype
Table
The same codes work in both directions: strptime reads them out of text and strftime writes them into text. %m is the month and %M is the minute, and swapping the two is the classic typo.
CodeReads or writesIn 2026-03-08 01:59:30
%Yfour-digit year2026
%mzero-padded month number03
%dzero-padded day of the month08
%Hhour on a 24-hour clock01
%Mminute59
%Ssecond30
%zoffset from UTC, such as +0000absent here, so the result is naive
The same codes work in both directions: strptime reads them out of text and strftime writes them into text. %m is the month and %M is the minute, and swapping the two is the classic typo.
Check yourself
A vendor changes its export from 2026-03-08 to 2026/03/08 and your loader still calls datetime.strptime(text, '%Y-%m-%d'). What happens on the first row?

The format has to match the input exactly, separators included. A mismatch raises ValueError rather than guessing, which is the behaviour you want: a loud failure at the boundary beats a silently wrong date in a report. When the text is already ISO 8601, skip the format string entirely and call datetime.fromisoformat, which is faster and impossible to mistype.

Subtracting two datetimes gives a timedelta

from datetime import datetime, timedelta

start = datetime(2026, 3, 8, 1, 0, 0)
end = datetime(2026, 3, 9, 1, 0, 30)
gap = end - start             # timedelta(days=1, seconds=30)
gap.total_seconds()           # 86430.0
start + timedelta(hours=2)    # 2026-03-08 03:00:00
Check yourself
gap = datetime(2026, 3, 9, 1, 0, 30) - datetime(2026, 3, 8, 1, 0, 0). What does gap.seconds return?

A timedelta stores days, seconds, and microseconds as three separate fields, so gap.seconds is the leftover inside the final day and not the total. Ask for gap.total_seconds() whenever you want one number, then divide.

Naive vs aware: the one that pages you at 3am

A naive datetime carries no timezone, so it is a wall-clock reading with no way to know which wall. An aware datetime carries a tzinfo, so it pins an exact instant. datetime.now() hands you a naive one; datetime.now(timezone.utc) hands you an aware one.

from datetime import datetime, timezone

naive = datetime(2026, 3, 8, 1, 0, 0)
aware = datetime(2026, 3, 8, 1, 0, 0, tzinfo=timezone.utc)
naive.utcoffset()   # None, this one does not know
aware.utcoffset()   # 0:00:00
Check yourself
A log parser produces naive datetimes and an API client produces aware UTC ones. Your latency metric subtracts one from the other. What happens?

Python refuses to subtract or order a naive datetime against an aware one, because there is no honest answer. The rule that keeps this out of a codebase for good: store and compute in UTC, and convert to local only when you show a value to a human. Parse at the boundary into aware UTC, compare there, and format for display last. The Practice exercise makes you enforce exactly that rule before it trusts a subtraction.

Pitfalls

  • datetime.utcnow() returns a naive datetime that happens to hold UTC time, which is the worst of both worlds: it looks safe and it compares wrong. Use datetime.now(timezone.utc).
  • Adding timedelta(days=1) adds exactly 24 hours. Across a daylight-saving change that is not the same wall-clock time the next day, which is why "every day at 09:00" jobs drift twice a year.
  • fromisoformat returns an aware datetime when the text carries an offset like +00:00 and a naive one when it does not. One function, two kinds of result, so check utcoffset() instead of assuming.

Interview nuance: "naive or aware?" is the fastest way to sound like you have run a system in production. Say that you normalise to aware UTC at every input boundary, keep UTC in storage and in every comparison, and render local time only in the presentation layer. Then name the two failure modes you are buying your way out of: the TypeError when the two kinds meet, and the silent off-by-an-offset duration you get in any language that coerces instead of raising.

Worked example (Python)
from datetime import datetime, timedelta, timezone

parsed = datetime.strptime("08/03/2026 01:59:30", "%d/%m/%Y %H:%M:%S")
print(parsed.isoformat())          # 2026-03-08T01:59:30

gap = datetime(2026, 3, 9, 1, 0, 30) - datetime(2026, 3, 8, 1, 0, 0)
print(gap.seconds, gap.total_seconds())   # 30 86430.0

print(datetime(2026, 3, 8, tzinfo=timezone.utc).utcoffset())   # 0:00:00
print(datetime(2026, 3, 8).utcoffset())                        # None

Apply

Your turn

The task this lesson builds to.

Write a function to_iso(stamp) that returns the ISO 8601 form of a day-first timestamp.

The input looks like "08/03/2026 01:59:30", meaning day, then month, then year. Return "2026-03-08T01:59:30".

3 hints and 4 automated checks are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, so it survives past today.

An incident timeline stitches two sources together: log lines that carry no timezone at all, and API records that carry a UTC offset. Subtracting one from the other is exactly the bug that reported a five hour outage as a twelve hour one.

Implement safe_minutes_between(started, finished): parse both ISO 8601 strings with datetime.fromisoformat and return the whole minutes between them as an int. When one value carries a UTC offset and the other does not, return None instead of a number, because the two are not comparable.

3 hints and 4 automated checks are waiting in the workspace.