Topic 7.11
Dates, Times and Time Zones
In one line
Python's datetime module has types for a date, a time of day, both together, and a length of time. Keep every moment as a time-zone-aware value, store and compare in UTC, show local time only at the edge, and write dates as ISO 8601 text.
Think of it like this
A phone call between two cities. You say "let's talk at 3 o'clock", and your friend, in another country, says "3 o'clock when?" The number 3 means nothing until you say whose clock. A date and time written without a time zone is the same: it names a spot on a clock face, not a moment in history. Programs that forget the "whose clock" part book meetings at the wrong hour.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- datetime
- A Python object holding a date and a time of day, and optionally a time zone.
- timedelta
- A length of time, such as 2 days and 3 hours. It is not a point on the calendar.
- Naive datetime
- A datetime with no time zone. It is just a clock reading, so it is ambiguous.
- Aware datetime
- A datetime that knows its offset from UTC, so it names one exact moment.
- UTC
- Coordinated Universal Time, the world's reference clock. It has no daylight saving.
- Offset
- How far a local clock is from UTC, such as +05:30 for India.
- Daylight saving time (DST)
- Moving the clocks forward in spring and back in autumn in some places.
- ISO 8601
- The international way to write dates and times: 2026-03-08T14:30:05.
- Epoch and timestamp
- The epoch is 00:00 UTC on 1 January 1970. A timestamp counts the seconds since then.
- Monotonic clock
- A clock that only moves forward and is not changed by anyone, so it is safe for measuring durations.
Step by step
01Four building blocks
date is a day, time is a time of day, datetime is both, and timedelta is a distance between two moments. The numbers go in from the largest unit to the smallest: year, month, day, hour, minute, second.
In the code below, moment + timedelta(...) moves the moment forward, and one date minus another gives the gap between them. Also useful: date(2026, 3, 8).weekday() gives 0 for Monday up to 6 for Sunday, and strftime("%A") gives the name.
from datetime import date, datetime, timedelta
moment = datetime(2026, 3, 8, 14, 30, 5)
print(moment + timedelta(days=2, hours=3))
print(date(2026, 12, 25) - date(2026, 10, 10))02Subtracting gives a timedelta
The difference between two dates is a timedelta. It has .days, .seconds and .total_seconds(). Careful: .seconds is only the leftover seconds after whole days. So timedelta(days=2, hours=3).seconds is 10800 (three hours), not 183600. Use total_seconds() for the whole length.
There is no timedelta(months=1), because months have different lengths. Add 30 days, or compute the first day of the next month yourself. Libraries such as python-dateutil (relativedelta) and pandas offer calendar-month arithmetic.
03Text in, text out: strftime and strptime
strftime formats; strptime parses. They use the same codes, so you can check one against the other.
Use strptime only for the formats other people give you (25/12/2026). For your own storage use ISO 8601 and isoformat / fromisoformat.
from datetime import datetime
moment = datetime(2026, 3, 8, 14, 30, 5)
print(moment.strftime("%d %B %Y, %I:%M %p"))
parsed = datetime.strptime("25/12/2026 09:15", "%d/%m/%Y %H:%M")
print(parsed.isoformat())04Naive and aware
Give a datetime a zone and it becomes a real moment. timezone.utc is the built-in UTC. ZoneInfo("Asia/Tokyo") is a named zone with its full history of rules.
Two aware values are equal when they are the same instant, even if they show different clock times: noon UTC equals 21:00 in Tokyo.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
utc = datetime(2026, 7, 1, 12, 0, tzinfo=timezone.utc)
tokyo = utc.astimezone(ZoneInfo("Asia/Tokyo"))
print(tokyo)
print(utc == tokyo)05Why the name of the zone matters
In London in January the offset is +00:00 (GMT). In July it is +01:00 (BST). A zone is not one number; it is a rule book with history. ZoneInfo carries the rule book, so the same wall-clock time gets the right offset for its date.
On Linux and macOS, zoneinfo reads the system's time-zone files. On Windows there are none, so install the tzdata package (pip install tzdata). In the browser on this page the time-zone data loads for you.
06The daylight-saving trap
On 8 March 2026 New York's clocks jump from 02:00 to 03:00, so that day has only 23 hours. Add timedelta(days=1) to noon on 7 March and Python keeps the wall clock: you get noon on 8 March, which is really only 23 hours later. Two aware values with the same tzinfo are subtracted by wall clock, so Python even reports 24 hours.
To add real elapsed time, convert to UTC, add, and convert back. There are two other oddities. A time can not exist (02:30 on that spring morning). A time can happen twice (01:30 on 1 November 2026, when clocks go back). Python accepts both. The fold attribute (0 or 1) chooses the first or second occurrence. Best practice: avoid local arithmetic across a DST change and keep everything in UTC.
07Timestamps, the epoch, and measuring time
A timestamp is one number, so it is easy to store and compare. datetime.fromtimestamp(0, tz=timezone.utc) is the epoch: 1970-01-01 00:00 UTC. Always pass tz=; without it you get a naive local time.
To time a piece of code use time.perf_counter() (best resolution) or time.monotonic(). Both are clocks that only go forward. The wall clock (datetime.now()) can be adjusted by the operating system or network time sync, even backwards, which would give negative durations.
import time
start = time.perf_counter()
total = sum(range(100_000))
elapsed = time.perf_counter() - start
print(f"took {elapsed:.4f} seconds") # the number differs on every runTry it yourself
- 1
Predict the weekday
In the first example, change the date to your birthday this year and write down the weekday before you run it. Then change the format to
%a %d %b %Yand see the short names. - 2
Move a meeting around the world
In the second example, create
datetime(2026, 7, 1, 15, 0, tzinfo=ZoneInfo("Asia/Kolkata"))and convert it toUTC,America/New_YorkandAustralia/Sydney. Work out the answers on paper first. Sydney is on winter time in July, so the offset is not what you might expect. - 3
Cross the clock change yourself
In the daylight-saving example, change the start date to
datetime(2026, 3, 28, 12, 0, tzinfo=london). London's clocks go forward on 29 March 2026. Predict whether+ timedelta(days=1)and a UTC-based 24 hours still agree, then check.
Code & diagrams
Every date here is fixed, so the output is the same on every run. Change the format codes and predict the text first.
Expected output
date: 2026-03-08
time: 14:30:05
datetime: 2026-03-08 14:30:05
parts: 2026 3 8 14 30
weekday (Mon=0): 6 Sunday
later: 2026-03-10 17:30:05
difference: 76 days, 0:00:00
days only: 76
total seconds: 183600.0
08 March 2026, 02:30 PM
2026-03-08
parsed: 2026-12-25 09:15:00 datetime
2026-03-08T14:30:05
2026-03-08 14:30:05
True
ValueError: time data '2026-13-01' does not match format '%Y-%m-%d'Look at the two lines for 8 March: the wall clock says noon, the real 24 hours say one o'clock. Then read the two 1:30 lines of 1 November.
Expected output
naive tzinfo: None
aware: 2026-07-01 12:00:00+00:00 UTC
in London: 2026-07-01 13:00:00+01:00
in Tokyo : 2026-07-01 21:00:00+09:00
same instant: True
TypeError: can't compare offset-naive and offset-aware datetimes
2026-01-15T09:00:00+00:00 GMT 0:00:00
2026-07-15T09:00:00+01:00 BST 1:00:00
wall-clock +1 day: 2026-03-08 12:00:00-04:00
real 24 hours : 2026-03-08 13:00:00-04:00
hours between wall times: 24.0
hours really passed : 23.0
2:30 in the gap: 2026-03-08T02:30:00-05:00 -> UTC 2026-03-08T07:30:00+00:00
1:30 first : 2026-11-01T01:30:00-04:00 EDT
1:30 second: 2026-11-01T01:30:00-05:00 ESTThe timing line prints only true or false, because the real duration changes on every run.
Expected output
1970-01-01 00:00:00+00:00
timestamp: 1791633600.0 1791633600
back: 2026-10-10T12:00:00+00:00
2026-10-10 12:00:00+00:00
2026-10-10 17:30:00+05:30
2026-10-10 12:00:00+00:00
sum: 4999950000
elapsed is a non-negative float: True
monotonic never goes back: True
stored: 2026-10-10T12:00:00+00:00
shown : 10 Oct 2026 17:30 ISTA month is not 30 days, 12/10/2026 has two meanings, and replace() labels while astimezone() converts.
Expected output
2026-03-02
28 31
-7
2026-10-12 2026-12-10
2028-02-29 2027-03-01
ValueError: day is out of range for month
ZoneInfoNotFoundError 'No time zone found with key Mars/Olympus'
2026-07-01T09:00:00+05:30
2026-07-01T04:30:00+01:00Not runnable with a fixed output: it prints the moment you run it. Prefer an aware now(). datetime.utcnow() and utcfromtimestamp() are deprecated since Python 3.12 because they return a naive value in UTC, which is easy to mistake for local time.
from datetime import datetime, timezone
now_utc = datetime.now(timezone.utc) # aware, in UTC: use this
today = datetime.now().date() # naive, local calendar day
print(now_utc.isoformat())
# Avoid:
# datetime.utcnow() # naive value that is really UTC (deprecated since Python 3.12)Log lines usually start with an ISO 8601 time in UTC. `fromisoformat` reads it, and subtracting two aware values gives a timedelta. `zip(times, times[1:])` pairs each line with the next one (Topic 2.6).
Expected output
'model replied' came 2 s after the previous line
'request finished' came 83 s after the previous line
whole request took 0:01:25Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Compare a naive time with an aware one
Compare a deadline that has a zone with a submission time that does not.
from datetime import datetime, timezone
deadline = datetime(2026, 7, 1, 12, 0, tzinfo=timezone.utc)
submitted = datetime(2026, 7, 1, 11, 0)
print(submitted < deadline)Break #2
Parse a date with the wrong format
Parse a day/month/year string with a year-first format.
from datetime import datetime
print(datetime.strptime("10/12/2026", "%Y-%m-%d"))Myth vs fact
Myth
replace(tzinfo=...) converts a time to that zone.
Fact
It only attaches a label and leaves the clock numbers alone. astimezone(zone) is the conversion. Labelling a UTC value as Tokyo gives a time nine hours wrong.
Myth
A day always has 24 hours, and timedelta(days=1) is always the same as 24 hours.
Fact
In zones with daylight saving, one day a year has 23 hours and another has 25. Python's local arithmetic keeps the wall clock; only UTC arithmetic gives true elapsed time.
Myth
Storing local time is fine if all my users are in one country.
Fact
Even one country changes its clocks, and users travel. Server logs, deployments and cloud services mostly run in UTC. Store UTC and convert when displaying.
Myth
datetime.now() is the safe way to get the current time.
Fact
It returns a naive local time, which is easy to misread later. Use datetime.now(timezone.utc) for an aware value.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Python's
datetimestores whole microseconds. Parsing text with nanoseconds, as many logging systems and databases produce, loses digits:fromisoformatin 3.11 and later silently truncates after six fractional digits, and older versions reject the text, so decide how to round on purpose. Databases often storetimestamp with time zoneas UTC and return it in the session's zone; check what your driver gives you. - ▸
zoneinforeads the IANA time-zone database, which is updated several times a year as governments change rules. Servers and containers need currenttzdata(the system package or thetzdatapackage on PyPI). Keep long-lived timestamps in UTC and the rules never matter for storage; they matter only when you convert a *future* local time, because the rules for next year may still change. - ▸
For future events (a meeting at 09:00 in Berlin next October) store the local time and the zone name, not the UTC instant, because if the rules change, the instant should move with them. For past events (a payment happened) store the UTC instant.
- ▸
Version differences that bite. Before Python 3.11,
fromisoformatcould only read whatisoformatwrites, so"2026-10-10T12:00:00Z"raisedValueError(people wrote.replace("Z", "+00:00")); 3.11 acceptsZand most ISO 8601 shapes.datetime.utcnow()andutcfromtimestamp()are deprecated since 3.12 because they return a naive value that merely holds UTC numbers. Usedatetime.now(timezone.utc), ordatetime.UTC(an alias oftimezone.utcsince 3.11). - ▸
More traps.
naive.timestamp()assumes the naive value is in the machine's local zone, so the same code gives different numbers on a laptop and a UTC server.strptimewith%ymaps 69 to 1969 and 68 to 2068, so prefer four-digit years.%Zparses only a few names, so do not use it for real zone handling. Withzoneinfo,replace(tzinfo=ZoneInfo(...))is the correct way to label a naive local time (unlike the oldpytz, which neededlocalize).datetimehas no leap seconds:23:59:60raisesValueError. - ▸
Pandas uses its own
Timestampanddatetime64[ns]types with the same ideas (naive versustz-aware,tz_localizeto label andtz_convertto convert).time.perf_counter()is the best clock for short measurements;time.time()is the wall clock in seconds since the epoch and can jump.
Remember this
- 1
The
datetimemodule (you bring it in withfrom datetime import ..., Topic 6.1) gives you four building blocks.date(2026, 3, 8)is a calendar day.time(14, 30)is a time of day.datetime(2026, 3, 8, 14, 30)is both.timedelta(days=2, hours=3)is a length of time, not a point in time. Adding atimedeltato adatetimemoves it. Subtracting twodatetimevalues gives atimedelta. All of these are immutable (they never change; Topic 3.5): every calculation returns a new object. - 2
To turn a date into text, use
strftime("string from time") with codes.%Yis the four-digit year,%mthe month number and%dthe day.%His the hour on a 24-hour clock, and%Iwith%pgives a 12-hour clock with AM or PM.%Ais the weekday name and%Bthe month name. To turn text into a date, usestrptime("string parsed into time") with the same codes. Python raisesValueErrorif the text does not fit the format. The month and day order is not guessed:12/10/2026is 12 October in%d/%m/%Yand 10 December in%m/%d/%Y. - 3
For storing and sending dates, use ISO 8601:
2026-03-08T14:30:05, year first, largest unit to smallest. It sorts correctly as plain text, nobody can confuse day and month, and every language and database understands it.dt.isoformat()writes it anddatetime.fromisoformat(text)reads it. Since Python 3.11,fromisoformatalso reads a trailingZ(meaning UTC) and most other ISO 8601 shapes. - 4
A
datetimeis either naive (no time zone:tzinfoisNone) or aware (it knows its offset from UTC, meaning how many hours and minutes its clock is ahead of or behind UTC). A naive value is only a clock reading and is ambiguous. Python refuses to order or subtract a naive and an aware value (<,>and-raiseTypeError: can't compare offset-naive and offset-aware datetimes).==does not raise but always answersFalse. That is a kindness, because it stops a silent bug. Make values aware as early as possible, ideally the moment they enter your program. - 5
zoneinfo(Python 3.9) knows the world's time zones by name:ZoneInfo("Europe/London"). Use names, never a hand-typed offset like+01:00. The offset of one place changes during the year (daylight saving time, abbreviated DST) and over the years (governments change the rules).dt.astimezone(zone)converts an aware value to another zone without changing the instant (the one exact moment in history).dt.replace(tzinfo=zone)only attaches a label to a naive clock reading and changes nothing else. Mixing the two up is a very common bug. - 6
The golden rule: inside your program use UTC. Convert to a local zone only when showing a time to a person, and convert back to UTC when you receive one. Store UTC in files and databases.
- 7
A timestamp (also Unix time or epoch time) is a count of seconds since 00:00 UTC on 1 January 1970, the epoch.
dt.timestamp()anddatetime.fromtimestamp(seconds, tz=timezone.utc)convert. To measure how long something took, never subtract two wall-clock times (the wall clock is the ordinary clock on the wall, which can be corrected or jump for DST). Usetime.monotonic()ortime.perf_counter(), which only move forward. Timestamps and logs in AI systems follow the same rule: UTC, ISO 8601. Examples are the time a model call started and when a document was crawled.
Explain it without notes
What is the difference between a naive and an aware datetime, and why does Python refuse to compare them?
Why should a program store times in UTC, and when should it convert to a local zone?
What is the difference between astimezone and replace(tzinfo=...)?
Describe a daylight-saving bug and how to avoid it.
Why use time.monotonic() or perf_counter() to measure a duration instead of two datetime.now() calls?
Practice
Write code that prints how many days are left between 10 October 2026 and 25 December 2026, in the form Days left: 76. Hint: subtract two date objects and read .days.
Parse the text "03 Mar 2026 18:45" with strptime and print the result in ISO 8601 (it should print 2026-03-03T18:45:00). Hint: %b is the short month name.
A meeting is at 15:00 in Asia/Kolkata on 1 July 2026. Print its time in UTC (HH:MM) and in America/New_York (HH:MM followed by the zone abbreviation, from %Z). Hint: make the meeting aware first, then use astimezone.
Write last_day(year, month) that returns the last date of a month. Find the first day of the next month and subtract one day. Print last_day(2028, 2) and last_day(2026, 12) on one line. Hint: December needs the year to go up by one and the month to wrap to 1.
Trade-offs
- ↔
Aware UTC everywhere versus local time. UTC is simple, comparable and safe, but must be converted for display. Local time reads naturally but is full of traps. Use UTC inside and local at the edges. The exception is a future event in a specific place, where you keep the local time and the zone name.
- ↔
Standard library versus helper libraries.
datetimeandzoneinfoare enough for most work and have no dependencies.python-dateutil,pendulumandarrowadd friendlier parsing and calendar-month arithmetic, at the price of one more dependency and another API to learn. - ↔
ISO 8601 text versus timestamps. ISO text is readable and carries the offset; a number is compact and easy to compare but unreadable in a log. Pick one per system and stick to it.
Done when you can
Done when you can build and subtract
date,datetimeandtimedeltavalues and read.daysandtotal_seconds().Done when you can format with
strftime, parse withstrptimeand read and write ISO 8601 withfromisoformatandisoformat.Done when you can explain naive versus aware and convert between zones with
ZoneInfoandastimezone.Done when you can explain a daylight-saving bug with a real example and avoid it with UTC.
Done when you can convert a timestamp to a datetime and back, and time code with
perf_counter.