Dates, times & the naive-vs-aware trap
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
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
| Code | Reads or writes | In 2026-03-08 01:59:30 |
|---|---|---|
| %Y | four-digit year | 2026 |
| %m | zero-padded month number | 03 |
| %d | zero-padded day of the month | 08 |
| %H | hour on a 24-hour clock | 01 |
| %M | minute | 59 |
| %S | second | 30 |
| %z | offset from UTC, such as +0000 | absent here, so the result is naive |
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
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
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. Usedatetime.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. fromisoformatreturns an aware datetime when the text carries an offset like+00:00and a naive one when it does not. One function, two kinds of result, so checkutcoffset()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.
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()) # NoneApply
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.