Dates, times and time zones
datetime, timedelta, formats, time zones and the traps when clocks change.
Why it matters
Almost every real program touches time: orders have timestamps, reminders fire tomorrow, reports cover last month, stores open at 10 in their own time zone. Time also hides some of the most common bugs in software: a reminder an hour late after the clocks change, a "today" that's already tomorrow in another city. This lesson gives you the tools and the habits that avoid them.
What you'll be able to do
- Work with date, datetime and timedelta: build them, compare them and do arithmetic with them
- Turn dates into text and text into dates with strftime, strptime and ISO 8601
- Use time zones with zoneinfo, and tell aware datetimes from naive ones
- Avoid daylight-saving traps, and write time-dependent code you can test by passing now in
What it covers
- Dates, datetimes and durations
- Dates as text
- Time zones: aware and naive
- When the clocks change
- What time is it there?
- Storing and sending times
- Pass "now" in
- Worked example: when does each store open next?
Dates, datetimes and durations
The datetime module has three types you'll use constantly:
- a date is a day on the calendar: 2025-03-14;
- a datetime is a moment: a date plus a time of day;
- a timedelta is a length of time: 3 days, or 90 minutes.
from datetime import date, datetime, timedelta launch = date(2025, 3, 14) print(launch, launch.year, launch.strftime("%A")) order_placed = datetime(2025, 3, 14, 9, 12) delivered = order_placed + timedelta(days=3, hours=4) print(delivered) print(delivered - order_placed) print((delivered - order_placed).total_seconds() / 3600, "hours") print(delivered > order_placed)
Adding a timedelta to a datetime gives a new datetime. Subtracting two datetimes gives the timedelta between them, and total_seconds() turns it into one number. Dates and datetimes compare with < and > the way you'd expect: earlier is smaller. .weekday() numbers the days from Monday as 0 to Sunday as 6, and strftime("%A") spells the day out.
A date has no time; a datetime is one moment; a timedelta is a length of time, with no start date.
Dates as text
Dates arrive as text, and reports need them as text, so you'll convert both ways. strftime ("string from time") formats a date with codes like %Y for the year. strptime ("string parse time") reads one, using the same codes to describe the text's shape:
from datetime import datetime placed = datetime(2025, 3, 14, 9, 12) print(placed.strftime("%B %d, %Y at %I:%M %p")) print(placed.strftime("%d/%m/%Y")) typed = datetime.strptime("06/09/2024 14:30", "%m/%d/%Y %H:%M") print(typed) print(placed.isoformat()) print(datetime.fromisoformat("2025-03-14T09:12:00"))
| Code | Means | Example |
|---|---|---|
%Y %m %d | year, month, day (with zeros) | 2025, 03, 14 |
%H %M %S | hour (24-hour clock), minute, second | 09, 12, 00 |
%I %p | hour on the 12-hour clock, AM or PM | 09, AM |
%A %B | day name, month name | Friday, March |
For storing and exchanging dates, use ISO 8601: 2025-03-14T09:12:00. It's the format APIs and databases expect. It sorts correctly as plain text, and nobody has to guess whether 06/09 means June or September. isoformat() writes it and fromisoformat() reads it.
from datetime import date, timedelta d = date(2025, 2, 27) + timedelta(days=3) print(d, d.strftime("%a"))
Show the answer
2025-03-02 Sun
2025 isn't a leap year, so February has 28 days: the 27th plus 3 days is March 2, a Sunday. Date arithmetic knows the calendar.
Time zones: aware and naive
"Portland opens at 10" means 10 in Portland. When it's 10 in Portland, it's 11 in Denver and 17:00 in London. A datetime that doesn't know its time zone is called naive: datetime(2025, 3, 14, 10, 0) could be any of those moments. A datetime with a time zone attached is aware, and points at one exact moment.
zoneinfo (in Python since 3.9) knows the world's time zones by name, including their daylight-saving rules:
from datetime import datetime, timezone from zoneinfo import ZoneInfo pacific = ZoneInfo("America/Los_Angeles") mountain = ZoneInfo("America/Denver") opens = datetime(2025, 3, 14, 10, 0, tzinfo=pacific) print(opens) print(opens.astimezone(mountain)) print(opens.astimezone(timezone.utc)) now_utc = datetime.now(timezone.utc) print(now_utc.tzinfo, now_utc.utcoffset())
tzinfo= attaches a zone. astimezone shows the same moment as the clock reads somewhere else. Use names like "America/Denver", never fixed offsets like -7, because the offset changes when the clocks do. A good habit for programs: store and compare times in UTC, and convert to a local zone only to show them to a person.
Python refuses to compare a naive datetime with an aware one (TypeError), and that's a feature. It stops you from mixing "10 o'clock somewhere" with a real moment.
When the clocks change
In the United States, clocks jump from 2:00 to 3:00 AM on the second Sunday of March. On March 9, 2025, that night is 23 hours long. That breaks an assumption hiding in a lot of code: that "the same time tomorrow" and "24 hours later" are the same thing.
from datetime import datetime, timedelta, timezone from zoneinfo import ZoneInfo pacific = ZoneInfo("America/Los_Angeles") saturday = datetime(2025, 3, 8, 10, 0, tzinfo=pacific) same_time_sunday = saturday + timedelta(days=1) print(same_time_sunday, "wall clock: 10:00 again") exactly_24h = (saturday.astimezone(timezone.utc) + timedelta(hours=24)).astimezone(pacific) print(exactly_24h, "24 real hours later")
Adding timedelta(days=1) to an aware datetime moves the wall clock: same time, next day. Because the clocks jumped forward that night, only 23 real hours pass. Doing the arithmetic in UTC adds real elapsed time: 24 hours later, the Portland clock reads 11:00. Decide which one you mean. For "remind me at the same time tomorrow", use wall-clock time. For "the delivery window is 24 hours", use UTC.
What time is it there?
A video call is booked for 4:30 PM in Denver on October 6, 2025. Set call to that aware datetime, in_portland to the same moment on Portland's clock, and in_utc to the same moment in UTC, as an ISO 8601 string.
from datetime import datetime, timezone from zoneinfo import ZoneInfo call = None in_portland = None in_utc = None
Storing and sending times
A few rules keep times correct as they move between your code, files, databases and APIs:
- Get the current time as an aware UTC moment:
datetime.now(timezone.utc). The olderdatetime.utcnow()returns a naive datetime that only looks like UTC, and Python now warns against it. - Write times as ISO 8601 with their offset, like
2025-03-14T17:12:00+00:00.isoformat()on an aware datetime includes the offset, so whoever reads it knows exactly which moment it is. - Keep the person's time zone as a name ("America/Denver") next to their account, and convert only when you show them a time.
- Store dates without times (birthdays, sale end dates) as plain
datevalues, so no time zone can shift them into the day before.
from datetime import datetime, timezone from zoneinfo import ZoneInfo stamp = datetime(2025, 3, 14, 17, 12, tzinfo=timezone.utc).isoformat() print(stamp) back = datetime.fromisoformat(stamp) print(back.astimezone(ZoneInfo("America/Denver")).strftime("%a %b %d, %I:%M %p %Z"))
Pass "now" in
Code that calls datetime.now() inside itself gives a different answer every time you run it, so you can't test it. Does "is the store open?" work at 9:59 on a Monday? You'd have to wait until then. The fix is a small habit: take the current time as a parameter.
def is_open(store, now): # now is passed in... ... is_open("seattle", datetime.now(timezone.utc)) # ...the real time, in the app is_open("seattle", datetime(2025, 3, 10, 9, 59, tzinfo=PACIFIC)) # any moment, in a test
The app passes the real time, and the checks pass whatever moment they want to test: a Monday, the night the clocks change, one second before closing. The function itself stays simple and predictable. You'll see the same idea again as "dependency injection" in the software-engineering phase. It's what makes code with time, randomness or network calls testable.
Worked example: when does each store open next?
Three stores, two time zones. Given any moment, find each store's next opening, shown on its own clock.
from datetime import datetime, timedelta, timezone from zoneinfo import ZoneInfo STORES = { "portland": {"zone": "America/Los_Angeles", "opens": 10, "closes": 19, "closed_days": []}, "seattle": {"zone": "America/Los_Angeles", "opens": 10, "closes": 18, "closed_days": [0]}, "denver": {"zone": "America/Denver", "opens": 11, "closes": 19, "closed_days": []}, } def next_opening(store, now): info = STORES[store] local = now.astimezone(ZoneInfo(info["zone"])) day = local.date() while True: opening = datetime(day.year, day.month, day.day, info["opens"], tzinfo=ZoneInfo(info["zone"])) if opening > local and day.weekday() not in info["closed_days"]: return opening day += timedelta(days=1) now = datetime(2025, 3, 9, 17, 30, tzinfo=timezone.utc) # Sunday morning in the US for name in STORES: print(f"{name:9} {next_opening(name, now).strftime('%a %b %d, %H:%M %Z')}")
now is passed in as a UTC moment, and each store converts it to its own clock before deciding anything. The loop walks forward a calendar day at a time: build that day's opening time in the store's zone, and return the first one that's still ahead and not a closed day. Seattle is closed on Mondays (weekday 0), so on a Sunday morning its next opening is Tuesday. %Z prints the zone's short name. It shows PDT and MDT here, because the clocks changed early that morning.
Practice what you just read
- 7 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Why store times in UTC and convert to local time only for display?