Dates and times
Work with date, datetime and timedelta, format and parse timestamps, and keep time zones straight with aware datetimes and zoneinfo.
What you will learn
- Build date and datetime values and do arithmetic with timedelta
- Format values with strftime and read them back with strptime or fromisoformat
- Explain the difference between naive and aware datetimes, and why mixing them fails
- Get the current time with datetime.now(tz=UTC) and convert between zones with zoneinfo
- Describe what happens to a wall-clock time in a daylight saving gap
On this page
Log lines, CSV exports and API responses are full of timestamps, and almost every realistic data task eventually has to compare two of them. Dates look simple until a clock moves forward an hour, or two people in different cities say “9 o’clock”. This lesson gives you the standard library’s tools, and one habit that avoids most of the trouble: know whether every datetime you hold has a time zone attached.
Everything here lives in the datetime module, plus zoneinfo for named time zones. Both are in the standard library.
Dates and datetimes
A date is a calendar day, and a datetime is a day plus a time of day. You build them from numbers, year first:
from datetime import date, datetime
launch = date(2026, 3, 8)print(launch)print(launch.year, launch.month, launch.day)print(launch.weekday(), launch.isoweekday())
meeting = datetime(2026, 3, 8, 14, 30)print(meeting)print(meeting.hour, meeting.minute)print(meeting.date())2026-03-082026 3 86 72026-03-08 14:30:0014 302026-03-08The pieces are plain attributes. weekday() counts Monday as 0, so Sunday is 6, while isoweekday() counts Monday as 1 and Sunday as 7. March 8, 2026 is a Sunday, hence 6 and 7. A datetime has hour, minute, second and microsecond as well, and .date() drops the time. Both types are immutable, like strings: every operation gives you a new value.
Invalid numbers raise ValueError straight away (date(2026, 2, 30) says “day is out of range for month”), so you never hold an impossible date.
Durations with timedelta
Subtracting two dates or datetimes gives a timedelta, a length of time. You can also build one yourself and add it to a date:
from datetime import date, datetime, timedelta
start = datetime(2026, 3, 8, 14, 30)print(start + timedelta(days=1, hours=2))print(start - timedelta(minutes=45))
gap = date(2026, 12, 25) - date(2026, 10, 12)print(gap)print(gap.days)
length = datetime(2026, 3, 9, 17, 15) - startprint(length)print(length.total_seconds())print(length.seconds)2026-03-09 16:30:002026-03-08 13:45:0074 days, 0:00:00741 day, 2:45:0096300.09900timedelta takes days, hours, minutes, seconds and weeks as keyword arguments, and they combine. A difference of two date values is a whole number of days, shown as 74 days, 0:00:00.
The last two lines are a classic trap. The meeting ran 1 day, 2 hours and 45 minutes, which is 96300 seconds in total. total_seconds() gives that. The attribute .seconds is only the leftover part after the days are taken out: 2 hours 45 minutes is 9900 seconds. When you want “how long in total”, use total_seconds().
Formatting and parsing
strftime (“string from time”) turns a value into text using format codes that start with %. strptime (“string parsed to time”) does the reverse and needs the same codes to describe the text:
from datetime import datetime
moment = datetime(2026, 3, 8, 14, 5)print(moment.strftime("%Y-%m-%d"))print(moment.strftime("%d/%m/%y %H:%M"))print(moment.strftime("%A %d %B %Y, %I:%M %p"))
parsed = datetime.strptime("08/03/2026 14:05", "%d/%m/%Y %H:%M")print(parsed)print(parsed == moment)
try: datetime.strptime("2026-03-08", "%d/%m/%Y")except ValueError as error: print(error)2026-03-0808/03/26 14:05Sunday 08 March 2026, 02:05 PM2026-03-08 14:05:00Truetime data '2026-03-08' does not match format '%d/%m/%Y'The codes you will use most:
| Code | Meaning | Example |
|---|---|---|
%Y, %y |
year, four or two digits | 2026, 26 |
%m, %d |
month, day, zero-padded | 03, 08 |
%B, %b |
month name, full or short | March, Mar |
%A, %a |
weekday name, full or short | Sunday, Sun |
%H, %M, %S |
hour (24-hour), minute, second | 14, 05, 00 |
%I, %p |
hour (12-hour) and AM/PM | 02, PM |
Month and weekday names follow the program’s locale, so on a non-English system %B may not print March. When strptime cannot match, it raises ValueError, which is how you detect bad input. An f-string accepts the same codes after a colon, as in f"{moment:%d %b %Y}".
ISO 8601: the format to exchange
Writing 08/03/2026 is ambiguous: it is the 8th of March in much of the world and the 3rd of August in the United States. ISO 8601 removes the doubt by putting the biggest unit first: 2026-03-08T14:05:09. Use it whenever dates are stored in files or sent between programs. Every date and datetime has isoformat(), and a matching fromisoformat() reads it back:
from datetime import date, datetime
moment = datetime(2026, 3, 8, 14, 5, 9)text = moment.isoformat()print(text)print(moment.isoformat(sep=" ", timespec="minutes"))print(date(2026, 3, 8).isoformat())
print(datetime.fromisoformat(text) == moment)print(date.fromisoformat("2026-03-08"))2026-03-08T14:05:092026-03-08 14:052026-03-08True2026-03-08sep changes the letter between date and time, and timespec chooses how much of the time to print. The round trip returns an equal value, with no format string to get wrong.
Wider ISO input
Requires version 3.11 or later Before 3.11, fromisoformat only read what isoformat writes. From Python 3.11 it accepts most ISO 8601 forms, including the compact style, a trailing Z for UTC, and week dates:
from datetime import datetime
print(datetime.fromisoformat("20260308T140509"))print(datetime.fromisoformat("2026-03-08T14:05:09Z"))print(datetime.fromisoformat("2026-03-08T14:05:09+05:30"))print(datetime.fromisoformat("2026-W10-7"))2026-03-08 14:05:092026-03-08 14:05:09+00:002026-03-08 14:05:09+05:302026-03-08 00:00:00“Most” is the careful word. It is not a full ISO 8601 parser, so unusual forms still raise ValueError. Reach for it first, and fall back to strptime when the text is in a custom shape.
Notice the two outputs with +00:00 and +05:30 at the end. Those values carry a time zone, which brings us to the main idea of the lesson.
Naive and aware
A datetime is naive if it has no time zone information, and aware if it does. You can see which by looking at .tzinfo:
from datetime import UTC, datetime, timedelta, timezone
naive = datetime(2026, 3, 8, 14, 30)aware = datetime(2026, 3, 8, 14, 30, tzinfo=UTC)print(naive.tzinfo)print(aware.tzinfo)print(aware)print(aware.isoformat())
plus_two = timezone(timedelta(hours=2))local = datetime(2026, 3, 8, 16, 30, tzinfo=plus_two)print(local)print(local == aware)print(local.astimezone(UTC))NoneUTC2026-03-08 14:30:00+00:002026-03-08T14:30:00+00:002026-03-08 16:30:00+02:00True2026-03-08 14:30:00+00:00A naive value is just a clock reading: “14:30”, with no hint of where. An aware value pins that reading to a real moment. UTC is Coordinated Universal Time, the reference every other zone is measured from. timezone(timedelta(hours=2)) makes a fixed offset of two hours, and local is the same instant as aware, seen from two hours east, so == says True. The method astimezone(UTC) converts a value to another zone without changing the moment it describes.
from datetime import UTC works from Python Requires version 3.11 or later. On older versions, write timezone.utc, which is the same object.
Never compare naive with aware
If one value knows where it is and the other does not, Python cannot say which is earlier, so ordering comparisons raise an error instead of guessing:
from datetime import UTC, datetime
naive = datetime(2026, 3, 8, 12, 0)aware = datetime(2026, 3, 8, 12, 0, tzinfo=UTC)
print(naive == aware)try: print(naive < aware)except TypeError as error: print("TypeError:", error)print(aware.replace(tzinfo=None) == naive)FalseTypeError: can't compare offset-naive and offset-aware datetimesTrueThe same applies to subtracting them. Note the quiet difference: == between a naive and an aware value does not raise, it just answers False, because they can never be equal. <, > and - raise TypeError. The fix is to make both sides the same kind, and the safest kind is aware. replace(tzinfo=None) strips the zone, which discards information, so use it only when you are sure the clock reading was already in the zone you want.
The current time
Ask for the time in a way that returns an aware value:
from datetime import UTC, datetime
now = datetime.now(tz=UTC)print(now.tzinfo)print(now.utcoffset())print(type(now).__name__)UTC0:00:00datetimeThe output of now() changes every second, so the example prints only what stays fixed. The rule of thumb for programs: work in UTC inside, convert to a local zone only at the edges where a person reads the time. Store and compare UTC, and format local times at the last moment.
datetime.now() with no argument returns a naive value in the computer’s local zone. That is fine for a quick script, but such a value is a trap as soon as the program moves to a server in another zone.
The old UTC functions
Requires version 3.12 or later Older code uses datetime.utcnow() and datetime.utcfromtimestamp() to get UTC. Both are deprecated since Python 3.12: they still work, and they warn. The problem is that they return naive values, so the clock reading says UTC but nothing in the object does, and a later .timestamp() or comparison treats it as local time:
import warningsfrom datetime import UTC, datetime
with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") old = datetime.utcfromtimestamp(0)
print(caught[0].category.__name__)print(old, old.tzinfo)
new = datetime.fromtimestamp(0, tz=UTC)print(new, new.tzinfo)DeprecationWarning1970-01-01 00:00:00 None1970-01-01 00:00:00+00:00 UTCReplace datetime.utcnow() with datetime.now(tz=UTC) and datetime.utcfromtimestamp(t) with datetime.fromtimestamp(t, tz=UTC). The result is the same instant, now with its zone attached. There is no announced removal date, but new code should not use them.
Named time zones with zoneinfo
A fixed offset like +02:00 is not enough for real cities, because the offset changes with daylight saving time. The zoneinfo module Requires version 3.9 or later knows the rules of the IANA time zone database, using names such as "Europe/London":
from datetime import UTC, datetimefrom zoneinfo import ZoneInfo
departure = datetime(2026, 7, 1, 9, 0, tzinfo=ZoneInfo("Europe/London"))print(departure)print(departure.utcoffset())print(departure.tzname())print(departure.astimezone(UTC))print(departure.astimezone(ZoneInfo("Asia/Tokyo")))
winter = datetime(2026, 1, 1, 9, 0, tzinfo=ZoneInfo("Europe/London"))print(winter, winter.tzname())2026-07-01 09:00:00+01:001:00:00BST2026-07-01 08:00:00+00:002026-07-01 17:00:00+09:002026-01-01 09:00:00+00:00 GMTThe same zone object gave +01:00 (British Summer Time) in July and +00:00 (GMT) in January: it picks the offset that applies on the date. astimezone(ZoneInfo(...)) converts a moment to another city’s clock, which is how you answer “what time is that for them?”.
The daylight saving gap
When clocks spring forward, an hour of the local day is skipped. In New York on March 8, 2026, the clock jumps from 02:00 straight to 03:00, so 02:30 never happens. Watch what the arithmetic does around it:
from datetime import UTC, datetime, timedeltafrom zoneinfo import ZoneInfo
new_york = ZoneInfo("America/New_York")
before = datetime(2026, 3, 8, 1, 30, tzinfo=new_york)print(before, before.tzname())
wall = before + timedelta(hours=1)print(wall, wall.tzname())
exact = (before.astimezone(UTC) + timedelta(hours=1)).astimezone(new_york)print(exact, exact.tzname())2026-03-08 01:30:00-05:00 EST2026-03-08 02:30:00-05:00 EST2026-03-08 03:30:00-04:00 EDTAdding one hour to an aware value in a named zone moves the clock reading forward by an hour and keeps the zone. That produced 02:30 EST, a time that does not exist on the New York calendar, and Python accepts it without a warning. Going through UTC does the arithmetic on the real timeline: one hour after 01:30 EST is 03:30 EDT.
So for durations measured in elapsed time (“the job runs for 3 hours”), convert to UTC, add the timedelta, and convert back. For calendar-style steps (“same time tomorrow”), wall-clock arithmetic is what you want, but check the result near a clock change. The opposite problem happens in autumn, when an hour repeats; datetime has a fold attribute to say which of the two you mean, and the zoneinfo documentation explains it.
Common pitfalls
- Comparing or subtracting naive and aware values.
<and-raiseTypeError, and==quietly returnsFalse. Make both sides aware. - Using
datetime.now()orutcnow()for timestamps. The first is naive and local, the second is naive and deprecated Requires version 3.12 or later. Usedatetime.now(tz=UTC). - Reading
.secondsas the total length. It is only the leftover seconds within the day. Usetotal_seconds(). - Adding a
timedeltain a named zone and trusting the result near a clock change. Add in UTC when you mean elapsed time. - Parsing ambiguous text like
03/04/2026. Store ISO 8601, and givestrptimean explicit format for anything else. - Attaching a zone with
replace(tzinfo=...)to a value that was in another zone. It relabels the clock reading without converting it. Useastimezoneto move between zones.
Given today = date(2026, 10, 12) and deadline = date(2026, 12, 25), print how many days are left, then the same distance as weeks and days, then the weekday name of the deadline. Expected output:
74 days left10 weeks and 4 daysFridayShow solution
from datetime import date
today = date(2026, 10, 12)deadline = date(2026, 12, 25)
left = deadline - todayprint(f"{left.days} days left")print(f"{left.days // 7} weeks and {left.days % 7} days")print(deadline.strftime("%A"))74 days left10 weeks and 4 daysFridaySubtracting two dates gives a timedelta, and .days is its whole-day count. Floor division and the remainder split 74 into 10 weeks and 4 days. %A prints the full weekday name.
A meeting starts at 09:00 in New York ("America/New_York") on Monday 9 March 2026. Print the start in UTC first, then in New York, London ("Europe/London") and Tokyo ("Asia/Tokyo"), each line showing the weekday, day, month, 24-hour time and the zone abbreviation. Expected output:
UTC: 2026-03-09 13:00New York: Mon 09 Mar 09:00 EDTLondon: Mon 09 Mar 13:00 GMTTokyo: Mon 09 Mar 22:00 JSTHint: build the start as an aware datetime, then call astimezone once per city. The abbreviation is tzname().
Show solution
from datetime import UTC, datetimefrom zoneinfo import ZoneInfo
start = datetime(2026, 3, 9, 9, 0, tzinfo=ZoneInfo("America/New_York"))
print("UTC:", start.astimezone(UTC).strftime("%Y-%m-%d %H:%M"))for city, zone in [ ("New York", "America/New_York"), ("London", "Europe/London"), ("Tokyo", "Asia/Tokyo"),]: local = start.astimezone(ZoneInfo(zone)) print(f"{city}: {local:%a %d %b %H:%M} {local.tzname()}")UTC: 2026-03-09 13:00New York: Mon 09 Mar 09:00 EDTLondon: Mon 09 Mar 13:00 GMTTokyo: Mon 09 Mar 22:00 JSTNew York has already moved to daylight time (EDT, UTC-4) on 8 March, while London stays on GMT until the end of the month, so for these three weeks the gap between them is four hours rather than the usual five. The zone database handles that, and your code never mentions an offset. Try the same program with the date changed to 2 March to see London and New York five hours apart.
What does this print? Write down all three lines before running it.
from datetime import UTC, datetime
naive = datetime(2026, 3, 8, 12, 0)aware = datetime(2026, 3, 8, 12, 0, tzinfo=UTC)
print(naive == aware)try: print(naive < aware)except TypeError as error: print("TypeError:", error)print(aware.replace(tzinfo=None) == naive)Show solution
FalseTypeError: can't compare offset-naive and offset-aware datetimesTrueThe two values show the same clock reading, but one has a zone and the other does not, so they are never equal and == says False without complaining. Ordering with < has no sensible answer, so it raises TypeError. Stripping the zone with replace(tzinfo=None) makes both naive, and then the readings match. That fix works here only because we know the naive value was meant to be UTC.
Summary
dateanddatetimehold calendar values, and subtracting them gives atimedelta. Usetotal_seconds()for the whole length, not.seconds.strftimeandstrptimeuse%codes to format and parse.isoformatandfromisoformatuse ISO 8601, which is the format to store and exchange. Requires version 3.11 or laterfromisoformataccepts most ISO 8601 text, includingZ.- A datetime is naive without a time zone and aware with one. Ordering comparisons and subtraction between the two raise
TypeError. - Requires version 3.11 or later Get the current time with
datetime.now(tz=UTC)afterfrom datetime import UTC. Requires version 3.12 or laterutcnow()andutcfromtimestamp()are deprecated because they return naive values. - Requires version 3.9 or later
zoneinfo.ZoneInfo("Region/City")gives named zones that follow daylight saving rules. Windows needs thetzdatapackage. Do elapsed-time arithmetic in UTC, because wall-clock arithmetic can land inside a skipped hour. - Tests that depend on the current time are hard to write. The mocking lesson shows how to replace the clock.