Python Module 6 – Modules, packages and the standard library
Dates, times and time zones (IST included)
Create, format and parse dates in Python, calculate with timedelta, and convert aware datetimes between time zones with zoneinfo, IST included.
What you will learn
- Create dates and datetimes, and format and parse them with strftime() and strptime()
- Calculate durations, deadlines and month steps with timedelta and calendar
- Distinguish naive from aware datetimes, and attach or convert a time zone with replace() and astimezone()
- Convert times between zones with zoneinfo, and handle daylight-saving gaps and repeated hours with UTC and fold
- Store and exchange times as UTC in ISO 8601
Before you start
What you’ll learn · Before you start
What you will learn
- Create dates and datetimes, and format and parse them with strftime() and strptime()
- Calculate durations, deadlines and month steps with timedelta and calendar
- Distinguish naive from aware datetimes, and attach or convert a time zone with replace() and astimezone()
- Convert times between zones with zoneinfo, and handle daylight-saving gaps and repeated hours with UTC and fold
- Store and exchange times as UTC in ISO 8601
Before you start
On this page
A weekly call at 19:30 in India is at 14:00 in London and 09:00 in New York, until March: then it moves to 10:00 in
New York, and two or three weeks later to 15:00 in London. Python’s datetime module and its companion zoneinfo
get such times right, as long as every time says which clock it belongs to.
datetime has four types for calendar work: date for a day, time for a clock reading, datetime for both and
timedelta for a length of time. A datetime is naive datetime: A datetime with no time zone: its tzinfo is None, so it is a clock reading that could belong to any place. When Python must convert one, it assumes the computer's local time. when it has no time zone and
aware datetime: A datetime that carries a tzinfo giving its offset from UTC, so it names one exact moment. datetime.now(UTC) and a datetime made with tzinfo=ZoneInfo("Asia/Kolkata") are aware. when it has one, and only an aware value names one moment that the whole world
agrees on. One habit prevents most bugs: make datetimes aware as soon as they enter the program, store and calculate in
UTC, and convert to a local zone only to show a time to a person.
date, time and datetime check their values and never change
Each of the four types holds one kind of value, and the program below makes the first three of them:
| Type | Holds | Made in this lesson as |
|---|---|---|
date |
a day: the year, the month and the day | date(2027, 3, 15) |
time |
a clock reading: hours, minutes, seconds | time(9, 30) |
datetime |
a day and a clock reading, with a time zone or without | datetime.combine(exam, start) |
timedelta |
a length of time | exam - today, in the next section |
from datetime import date, datetime, time
exam = date(2027, 3, 15)
print(exam, exam.year, exam.month, exam.day)
print(exam.strftime("%A"), exam.weekday(), exam.isoweekday())
print(exam.isocalendar())
start = time(9, 30)
slot = datetime.combine(exam, start)
print(slot, "|", slot.isoformat())
print(slot.replace(hour=14, minute=0))
print(date.fromisoformat("2027-03-15") == exam) Output
2027-03-15 2027 3 15 Monday 0 1 datetime.IsoCalendarDate(year=2027, week=11, weekday=1) 2027-03-15 09:30:00 | 2027-03-15T09:30:00 2027-03-15 14:00:00 True
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 date_basics.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
date(2027, 3, 15)checks its arguments, so a month 13 or a 31st day in April raisesValueError. Its parts are attributes;weekday()counts Monday as 0 andisoweekday()as 1, andisocalendar()gives the ISO 8601 week, week 11 here.datetime.combine()joins a date and a time. Dates and times are immutable: Unable to change once made. Strings, numbers and tuples are immutable: an operation that seems to change one, such as upper(), returns a new object instead. More in “Strings: literals, indexing and slicing”: they never change once made, soreplace()returns a copy with some fields changed.isoformat()andfromisoformat()write and read the international form, year first, which also sorts correctly as text.
Real programs ask date.today() or datetime.now(zone) for the current day and time. The examples here fix their dates
instead, so that their output never changes.
Ask for a day that does not exist
Press Edit, change the exam to date(2027, 4, 31) and run the program. It stops on that line with ValueError,
because April has 30 days, before anything is printed.
Checkpoint
Check: date, time and datetime
A timedelta counts days and seconds, never months
Subtracting one date from another gives a timedelta, and adding one moves a date. This program counts the days to an
exam, adds up study sessions and steps through months:
import calendar
from datetime import date, timedelta
today = date(2027, 1, 20) # a fixed day, so the output never changes
exam = date(2027, 3, 15)
left = exam - today
print(left, "|", left.days, "days")
print(left.days // 7, "weeks and", left.days % 7, "days")
print("revision starts", exam - timedelta(weeks=6))
study = timedelta(hours=2, minutes=45)
print(study * 30, "|", (study * 30) / timedelta(hours=1), "hours")
print(timedelta(hours=-1), "|", timedelta(days=1, minutes=90).total_seconds())
def add_months(day, months):
"""Move by whole months, keeping the day if the month has it, else its last day."""
index = day.month - 1 + months
year, month = day.year + index // 12, index % 12 + 1
last = calendar.monthrange(year, month)[1]
return day.replace(year=year, month=month, day=min(day.day, last))
print(add_months(date(2027, 1, 31), 1), add_months(date(2028, 1, 31), 1), add_months(date(2027, 11, 30), 3))
try:
date(2027, 1, 31).replace(month=2)
except ValueError as error:
print("ValueError:", error) Output
54 days, 0:00:00 | 54 days 7 weeks and 5 days revision starts 2027-02-01 3 days, 10:30:00 | 82.5 hours -1 day, 23:00:00 | 91800.0 2027-02-28 2028-02-29 2028-02-29 ValueError: day 31 must be in range 1..28 for month 2 in year 2027
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 days_until.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
- 54 days are left, and six weeks before the exam is
2027-02-01. - A
timedeltakeeps only days, seconds and microseconds; weeks, hours and minutes are converted when you make one.total_seconds()gives the whole length in seconds, and dividing by another timedelta asks “how many of these”: 30 sessions of 2 hours 45 minutes come to 82.5 hours. - Negative durations look odd:
timedelta(hours=-1)prints as-1 day, 23:00:00, minus one day plus 23 hours, because only the days may be negative. - A timedelta has no months, because a month has 28 to 31 days.
add_months()above moves the month number, keeps the day if the new month has it and otherwise takes its last day, whichcalendar.monthrange()gives.replace(month=2)alone fails ondate(2027, 1, 31), and the error says which days that February has.
The month arithmetic of add_months() is the one step a timedelta cannot do for you. Here it is at the top level of a
program, so that every line can be stepped through, for 1 and 13 months after the last day of January:
Program · month_steps.py
from calendar import monthrange
from datetime import date
day = date(2027, 1, 31)
for months in [1, 13]:
index = day.month - 1 + months
year = day.year + index // 12
month = index % 12 + 1
last = monthrange(year, month)[1]
keep = min(day.day, last)
print(date(year, month, keep)) Variables
- just taken by the loop
- changed in this step
- passed by the loop
- read next
- where the loop stopped
Printed so far
Nothing printed yet2027-02-28 2028-02-29
Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.
The drawing shows the variables when the program ends, and the program's 19 steps are listed below.
Change the program and run it here. Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every step as text
- Start. No line has run yet, so there are no variables. Python starts with line 1.
- Line 1.
from calendar import monthrangeloadsmonthrange. - Line 2.
from datetime import dateloadsdate. - Line 4.
day = date(2027, 1, 31)setsdaytodatetime.date(2027, 1, 31). - Line 5.
for months in [1, 13]:takes the item at position 0 of[1, 13]:monthsis1. - Line 6.
index = day.month - 1 + monthssetsindexto1. index counts months from January of the start year, with January as 0, so January plus 1 month is 1, February. - Line 7.
year = day.year + index // 12setsyearto2027. - Line 8.
month = index % 12 + 1setsmonthto2. - Line 9.
last = monthrange(year, month)[1]setslastto28. monthrange() gives the weekday of the first day and the number of days in the month; [1] takes the number of days. This February has 28. - Line 10.
keep = min(day.day, last)setskeepto28. Key point The day must exist in the new month, so min() takes 28 instead of 31. A timedelta cannot do this step, because it has no months. - Line 11.
print(date(year, month, keep))prints2027-02-28. - Line 5.
for months in [1, 13]:takes the item at position 1 of[1, 13]:monthsis13. - Line 6.
index = day.month - 1 + monthssetsindexto13(it was1). - Line 7.
year = day.year + index // 12setsyearto2028(it was2027). 13 months on, index is 13, so the year goes up by 13 // 12 = 1, to 2028. - Line 8.
month = index % 12 + 1monthkeeps its value,2. 13 % 12 + 1 = 2, so the month is February again, 12 months later than in the first pass. - Line 9.
last = monthrange(year, month)[1]setslastto29(it was28). - Line 10.
keep = min(day.day, last)setskeepto29(it was28). - Line 11.
print(date(year, month, keep))prints2028-02-29. - Line 5.
for months in [1, 13]:finds no item at position 2 of[1, 13](it has 2 items), so the loop ends. That was the last line, so the program ends. Both answers are the last day of February, the 28th in a common year and the 29th in a leap year. date(2027, 1, 31).replace(month=2) would have raised ValueError instead.
Both passes land on the last day of February, 28 in 2027 and 29 in the leap year 2028, where replace(month=2) would
have raised ValueError.
Checkpoint
Check: Durations with timedelta
The format, not the text, decides what a date means
strftime() and f-strings turn a date into text with strftime code: A code such as %d, %B or %H that stands for one part of a date or a time. It goes after the colon when an f-string formats a datetime, or into strftime(). More in “f-strings and the format mini-language”, and strptime()
reads text with the same codes:
import warnings
from datetime import date, datetime
text = "04-10-2026"
day_first = datetime.strptime(text, "%d-%m-%Y").date()
month_first = datetime.strptime(text, "%m-%d-%Y").date()
print(day_first, "|", month_first)
print(date.strptime(text, "%d-%m-%Y")) # new in Python 3.14
print(f"{day_first:%d %B %Y} | {day_first:%a %d/%m/%y}")
print(datetime.strptime("4-10-2026 9:05", "%d-%m-%Y %H:%M")) # one-digit fields are accepted
for bad in ["31-04-2026", "2026-10-04"]:
try:
datetime.strptime(bad, "%d-%m-%Y")
except ValueError as error:
print("ValueError:", error)
# A day and month without a year: the default year, 1900, is not a leap year
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
try:
datetime.strptime("29-02", "%d-%m")
except ValueError as error:
print("ValueError:", error)
print(caught[0].category.__name__)
print(datetime.fromisoformat("2026-10-04T09:30:00+05:30"))
print(datetime.fromisoformat("2026-10-04T04:00:00Z")) Output
2026-10-04 | 2026-04-10 2026-10-04 04 October 2026 | Sun 04/10/26 2026-10-04 09:05:00 ValueError: day 31 must be in range 1..30 for month 4 in year 2026 ValueError: time data '2026-10-04' does not match format '%d-%m-%Y' ValueError: day 29 must be in range 1..28 for month 2 in year 1900 DeprecationWarning 2026-10-04 09:30:00+05:30 2026-10-04 04:00:00+00:00
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 parse_dates.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The same text can mean two days, and some texts mean none. The program’s own inputs, side by side:
| Text | Format | What strptime() makes of it |
|---|---|---|
"04-10-2026" |
"%d-%m-%Y" |
2026-10-04, a day in October |
"04-10-2026" |
"%m-%d-%Y" |
2026-04-10, a day in April |
"31-04-2026" |
"%d-%m-%Y" |
ValueError: April has 30 days |
"2026-10-04" |
"%d-%m-%Y" |
ValueError: the text does not match the format |
"29-02" |
"%d-%m" |
ValueError: without a year it assumes 1900, not a leap year |
%dstands for the day,%mfor the month’s number,%Yfor the four-digit year,%Bfor the month’s name,%afor a short weekday and%H:%Mfor a 24-hour time. The codes, not the text, decide what each number means.date.strptime(), new in Python 3.14, returns adatestraight away, andtime.strptime()atime; on older versions, calldatetime.strptime()and then.date().- Parsing is strict about separators and about real dates, but it accepts days, months and hours written with one digit.
- Since Python 3.13 a format with a day but no year also raises
DeprecationWarning, and the documentation warns that it may become an error: always parse a year. fromisoformat()reads ISO 8601: The international standard for writing dates and times year first, then month, day and time, with an offset such as +05:30 or Z for UTC. isoformat() writes it and fromisoformat() reads it. text with its offset, such as+05:30, and since Python 3.11 also theZthat means UTC.
Checkpoint
Check: Formatting and parsing text
Only an aware datetime names one moment
A naive datetime is a clock reading; an aware one adds the zone, and with it the moment. This program attaches a zone, converts, makes the most common mistake on purpose and compares the results:
import warnings
from datetime import UTC, datetime, timedelta, timezone
naive = datetime(2026, 10, 4, 9, 30)
print(naive, "|", naive.tzinfo, naive.utcoffset())
ist = timezone(timedelta(hours=5, minutes=30), "IST")
aware = naive.replace(tzinfo=ist) # attach a zone: same wall time
print(aware, "|", aware.tzname(), aware.utcoffset())
print(aware.astimezone(UTC)) # convert: same moment, other clock
wrong = aware.replace(tzinfo=UTC) # the bug: same wall time, another moment
print(wrong, "|", wrong.astimezone(ist))
print(naive == aware, aware == aware.astimezone(UTC)) # never equal; the same moment
try:
naive < aware
except TypeError as error:
print("TypeError:", error)
# The current time: datetime.now(UTC), not the deprecated utcnow()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
old = datetime.utcnow()
print(old.tzinfo, "|", caught[0].category.__name__)
print(datetime.now(UTC).tzinfo, datetime.now(ist).utcoffset()) Output
2026-10-04 09:30:00 | None None 2026-10-04 09:30:00+05:30 | IST 5:30:00 2026-10-04 04:00:00+00:00 2026-10-04 09:30:00+00:00 | 2026-10-04 15:00:00+05:30 False True TypeError: can't compare offset-naive and offset-aware datetimes None | DeprecationWarning UTC 5:30:00
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 aware_naive.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Step through the first four lines of the output: what each call keeps, and what it changes.
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A naive reading. A naive datetime is a clock reading with no zone. 09:30 could be on a clock in Kolkata, in London or anywhere else, and Python cannot tell which.
- Attach India's zone. replace(tzinfo=ist) says which clock the reading belongs to and keeps it as it was. The value is now aware, 09:30 in India, one moment for the whole world.
- Convert to UTC. astimezone(UTC) keeps the moment and changes the clock, so 09:30 in India is 04:00 in UTC. Compared with ==, the two values are equal.
- The common bug. replace(tzinfo=UTC) on the Indian time keeps the reading, 09:30, but now claims it is UTC. The reading looks the same; the moment is five and a half hours later.
- Seen in India. Converted back to India's clock, the wrong value reads 15:00. A call booked for 09:30 would now appear in the afternoon.
- The rule. Use replace(tzinfo=…) once, to say which clock a naive reading came from, and astimezone() for every conversion after that.
- A naive datetime has
tzinfoset toNone: 09:30 could be on any clock in the world. When Python converts a naive value, intimestamp()orastimezone(), it assumes the computer’s local time, so the same code gives different answers on a laptop in India and on a server in Europe. replace(tzinfo=…)attaches a zone and keeps the clock reading;astimezone()converts to another zone and keeps the moment. Using one where the other was meant is the most common time-zone bug, the fourth line of the output.- A fixed offset,
timezone(timedelta(hours=5, minutes=30), "IST"), is all India needs for current dates. Zones with daylight saving time needzoneinfo, in the next section. - A naive and an aware datetime are never equal, and comparing them with
<raisesTypeError, as does subtracting one from the other. Two aware values compare by their moment: 09:30 in India equals 04:00 in UTC. datetime.utcnow()returns the current UTC time as a naive value, and it has been deprecated since Python 3.12. Usedatetime.now(UTC)(UTCis an alias oftimezone.utc, since 3.11), ordatetime.now(ZoneInfo("Asia/Kolkata"))for the time in India.
Calling a time UTC instead of converting it
aware.replace(tzinfo=UTC) on a time in India keeps 09:30 and claims it is UTC, which is 15:00 in India. Attach the
zone the reading came from once, naive.replace(tzinfo=ist), and convert with astimezone(UTC), which gives 04:00.
Checkpoint
Check: Naive and aware datetimes
zoneinfo knows when each region changes its clocks
zoneinfo.ZoneInfo(key) loads a zone from the IANA time zone database: The public record of the clock rules of every region, past and present, with keys such as Asia/Kolkata and Europe/London. zoneinfo reads it from the operating system or from the tzdata package.,
which records the offsets and the daylight-saving rules of places around the world, past and present. Keys name
regions, never abbreviations: Asia/Kolkata, Europe/London, America/New_York. Here the weekly call at 19:30 in
India meets two zones that change their clocks:
from datetime import datetime
from zoneinfo import ZoneInfo
KOLKATA = ZoneInfo("Asia/Kolkata")
OTHERS = {"London": ZoneInfo("Europe/London"), "New York": ZoneInfo("America/New_York")}
# A weekly call at 19:30 in India, on five Mondays around the clock changes
for day in (2, 9, 16, 23, 30):
call = datetime(2026, 3, day, 19, 30, tzinfo=KOLKATA)
local = [f"{city} {call.astimezone(zone):%H:%M %Z}" for city, zone in OTHERS.items()]
print(f"{call:%a %d %b %H:%M %Z} | " + " | ".join(local))
# India keeps one offset all year, with no daylight saving time
for month in (1, 7):
moment = datetime(2026, month, 15, 12, tzinfo=KOLKATA)
print(f"{moment:%b}", moment.utcoffset(), moment.dst(), moment.tzname())
# A short name is not a zone: "IST" means three different offsets
for key, month in (("Asia/Kolkata", 1), ("Europe/Dublin", 7), ("Asia/Jerusalem", 1)):
moment = datetime(2026, month, 15, 12, tzinfo=ZoneInfo(key))
print(f"{key:15} {moment.tzname()} {moment.utcoffset()}") Output
Mon 02 Mar 19:30 IST | London 14:00 GMT | New York 09:00 EST Mon 09 Mar 19:30 IST | London 14:00 GMT | New York 10:00 EDT Mon 16 Mar 19:30 IST | London 14:00 GMT | New York 10:00 EDT Mon 23 Mar 19:30 IST | London 14:00 GMT | New York 10:00 EDT Mon 30 Mar 19:30 IST | London 15:00 BST | New York 10:00 EDT Jan 5:30:00 0:00:00 IST Jul 5:30:00 0:00:00 IST Asia/Kolkata IST 5:30:00 Europe/Dublin IST 1:00:00 Asia/Jerusalem IST 2:00:00
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 meeting_times.py
Where the zone data comes from
zoneinfo reads the operating system’s copy of the database. Windows has none, and neither has Python in the
browser, where ZoneInfo() raises ZoneInfoNotFoundError; installing the tzdata package from PyPI fixes both,
and projects that must run everywhere should depend on it. That is also why the examples that use zoneinfo show
their recorded output here instead of a Run button.
The United States starts its daylight saving time: Moving the clocks forward by an hour in spring and back in autumn, as the United Kingdom and the United States do. India does not use it, so Asia/Kolkata keeps +05:30 all year., moving its clocks forward an hour, on the second Sunday of March, and the United Kingdom on the last Sunday, so for two or three weeks, depending on the year, New York is four hours behind London instead of five. In 2026, the year of the program, it is three. Step through the five Mondays on the three clocks:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The first Monday. The call is fixed at 19:30 on the Indian clock. On the first Monday of March, London is on GMT and New York on EST, so the call is at 14:00 and at 09:00 there.
- New York moves first. The United States moves its clocks forward on the second Sunday of March. On the next Monday New York is on EDT, four hours behind UTC, and the call is at 10:00 there.
- Three weeks apart. For the next three Mondays New York is four hours behind London instead of five. Nothing has changed in Kolkata or in London.
- London moves. The United Kingdom moves its clocks forward on the last Sunday of March. On the last Monday London is on BST, one hour ahead of UTC, and the call is at 15:00 there.
- India never moved. In Kolkata the call stayed at 19:30 on all five Mondays, because Asia/Kolkata is +05:30 all year, with no daylight saving time. Only the other two clocks moved.
The call moves an hour later in New York first and in London three weeks after, while in India it never moves:
Asia/Kolkata shows +05:30 and no daylight-saving offset in January and in July alike. The last lines of the program
show why you store the key and not the short name: IST stands for India Standard Time, for Irish Standard Time
(Dublin in summer) and for Israel Standard Time, three different UTC offset: How far a clock is ahead of or behind UTC, such as +05:30 for India Standard Time. A zone with daylight saving time has two offsets a year: Europe/London is +00:00 in winter and +01:00 in summer..
India Standard Time
IST, the one time zone of all of India, is UTC+05:30 all year, with no daylight saving time. That makes conversions
simple and easy to get wrong by half an hour: 14:00 UTC is 19:30 in India, not 19:00, and Nepal’s +05:45 shows that
offsets need not be whole hours either. Use the key Asia/Kolkata; the older name Asia/Calcutta still works.
Dates are usually written day first, so parse them with %d-%m-%Y and never guess the order. For old data, let the
database decide rather than a fixed offset: for a date in 1943, Asia/Kolkata gives +06:30, an offset India used
for a few years in the 1940s, as the last line of store_utc.py, at the end of this lesson, shows.
Checkpoint
Check: Time zones with zoneinfo
Adding a day across a clock change is not 24 hours
London’s clocks go forward on the last Sunday of March and back on the last Sunday of October. This program meets both nights:
from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo
LONDON = ZoneInfo("Europe/London")
# 1. Adding a day to an aware datetime keeps the wall clock, not 24 hours
before = datetime(2026, 3, 28, 12, 0, tzinfo=LONDON)
after = before + timedelta(days=1)
print(after, "|", after - before, "|", after.astimezone(UTC) - before.astimezone(UTC))
# 2. For elapsed time, do the arithmetic in UTC
print((before.astimezone(UTC) + timedelta(hours=24)).astimezone(LONDON))
# 3. In autumn, 01:30 happens twice: fold picks the first or the second
first = datetime(2026, 10, 25, 1, 30, tzinfo=LONDON)
second = first.replace(fold=1)
print(first, first.tzname(), "|", second, second.tzname())
print(second.astimezone(UTC) - first.astimezone(UTC))
# 4. In spring, 01:30 never happens: the round trip moves it
ghost = datetime(2026, 3, 29, 1, 30, tzinfo=LONDON)
print(ghost, "|", ghost.astimezone(UTC).astimezone(LONDON)) Output
2026-03-29 12:00:00+01:00 | 1 day, 0:00:00 | 23:00:00 2026-03-29 13:00:00+01:00 2026-10-25 01:30:00+01:00 BST | 2026-10-25 01:30:00+00:00 GMT 1:00:00 2026-03-29 01:30:00+00:00 | 2026-03-29 02:30:00+01:00
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dst_traps.py
Step through the spring night on a London clock and in UTC side by side; each row of the figure is one moment:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Saturday noon. Noon in London on the Saturday before the clocks go forward is 12:00 GMT, the same moment as 12:00 UTC, because in winter London's offset is zero.
- The clocks jump. At 01:00 GMT on Sunday the clocks jump to 02:00 BST, one hour ahead of UTC. On a London clock the hour from 01:00 to 02:00 never happens.
- Adding a day. before + timedelta(days=1) moves the wall clock to Sunday noon, 12:00 BST, which is 11:00 UTC. Subtracting the two says 1 day, but only 23 hours passed.
- 24 real hours. For 24 real hours, convert to UTC, add timedelta(hours=24) and convert back. That gives 12:00 UTC on Sunday, which a London clock shows as 13:00 BST.
- A time that never happens. 01:30 on that Sunday falls in the gap. zoneinfo accepts it anyway, with the offset from before the gap, and a round trip through UTC turns it into 02:30 BST.
- Adding
timedelta(days=1)to an aware datetime moves the wall clock: noon on the day before the clocks go forward becomes noon the next day, andafter - beforesays one day, but only 23 hours passed, as the subtraction in UTC shows. Python adjusts nothing for the time zone when it adds a timedelta, or when it subtracts two datetimes that share atzinfo. - For elapsed time (“in 24 hours”, a token that lives for a day, a timeout), convert to UTC, add, and convert back: 13:00 in London, an hour later on the clock than noon.
- When the clocks go back, 01:30 happens twice. The fold (datetime): The datetime attribute that picks one of two moments when a clock reading happens twice, as when the clocks go back: fold=0, the default, is the first, and fold=1 the second. attribute picks one:
fold=0, the default, is the first, still in summer time (BST, +01:00), andfold=1the second (GMT, +00:00), an hour later. - When the clocks go forward, 01:30 never happens.
zoneinfodoes not complain; it uses the offset from before the gap, and a round trip through UTC turns the time into 02:30 BST. Decide what such input should mean, and test it.
Key idea
Adding a timedelta to an aware datetime keeps the wall clock; for elapsed time, add in UTC and convert back, and pick
a repeated hour with fold.
Checkpoint
Check: Daylight-saving traps
Store UTC, show local
A booking made in India is stored once and read in many places. This program stores it in UTC, reads it back and shows it on two clocks:
from datetime import UTC, datetime
from zoneinfo import ZoneInfo
KOLKATA = ZoneInfo("Asia/Kolkata")
booked = datetime(2026, 10, 4, 9, 30, tzinfo=KOLKATA)
stored = booked.astimezone(UTC).isoformat() # what goes into the database
print(stored)
back = datetime.fromisoformat(stored)
print(back == booked, "|", back.astimezone(KOLKATA), "|", back.astimezone(ZoneInfo("Europe/London")))
stamp = booked.timestamp() # seconds since 1970-01-01 00:00 UTC
print(stamp, "|", datetime.fromtimestamp(stamp, UTC))
# Old data: the database knows that India's offset was not always +05:30
print(datetime(1943, 6, 1, 12, tzinfo=KOLKATA).utcoffset()) Output
2026-10-04T04:00:00+00:00 True | 2026-10-04 09:30:00+05:30 | 2026-10-04 05:00:00+01:00 1791086400.0 | 2026-10-04 04:00:00+00:00 6:30:00
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 store_utc.py
The whole path of a time typed in India, from the text to each reader’s clock:
Parse, attach the zone, store UTC, convert to show
Text description of the diagram
The diagram follows one time typed in India from top to bottom.
- The text, in the form DD-MM-YYYY HH:MM, is parsed with datetime.strptime(), which gives a naive datetime: a date and a clock reading without a time zone.
- replace(tzinfo=ZoneInfo("Asia/Kolkata")) says which clock the reading belongs to, without changing it. The datetime is now aware, at +05:30.
- astimezone(UTC) converts it to the same moment in UTC, at +00:00; a reading of 09:30 in India becomes 04:00 in UTC.
- isoformat() writes it as ISO 8601 text ending in +00:00, which is what the database keeps.
- To show it, datetime.fromisoformat() reads the text back, and astimezone() converts it to each reader's own zone.
- Convert to UTC before storing, and write ISO 8601 with its offset:
2026-10-04T04:00:00+00:00sorts correctly as text, names exactly one moment and reads back withfromisoformat()into an aware datetime equal to the original. Each reader then sees it on their own clock, 09:30 in India or 05:00 in London. - A POSIX timestamp, the number of seconds since the start of 1970 in UTC, is the other common form, and
datetime.fromtimestamp(stamp, UTC)turns it back into an aware datetime. Without the zone argument,fromtimestamp()returns the computer’s local time as a naive value. - To measure how long something takes, use
time.monotonic()ortime.perf_counter()rather than two readings ofdatetime.now(): the wall clock can jump when the computer sets it, and a monotonic clock never goes backwards.
Checkpoint
Check: Storing and exchanging times
Practise: IST to UTC and working days
Convert times typed in India to UTC, then count the working days between two dates with a list of holidays, across the ends of months and years.
Exercise · Medium · Python
Convert IST times to UTC and count working days
Write two functions in when.py.
ist_to_utc(text) reads a date and time typed in India in the form DD-MM-YYYY HH:MM, such as "04-10-2026 09:30", and returns the same moment in UTC as ISO 8601 text, "2026-10-04T04:00:00+00:00". Text in any other form must raise ValueError, which strptime() does for you. India Standard Time is UTC+05:30 all year, with no daylight saving time, so IST in the starter code, a fixed offset of 5 hours 30 minutes, is right for any date after 1945. Use it rather than ZoneInfo("Asia/Kolkata"): Python in the browser has no time-zone database, so zoneinfo raises ZoneInfoNotFoundError there.
business_days_between(start, end, holidays) counts the working days from start up to, but not including, end: the days from Monday to Friday that are not in holidays, a collection of date objects. When end is not after start, the answer is 0.
ist_to_utc("01-01-2027 03:00")is"2026-12-31T21:30:00+00:00": the moment falls in the previous year in UTCbusiness_days_between(date(2026, 10, 1), date(2026, 10, 8), [])is 5- the same week with
date(2026, 10, 2)as a holiday has 4 working days
Starter code · when.py
from datetime import UTC, date, datetime, timedelta, timezone
IST = timezone(timedelta(hours=5, minutes=30), "IST") # India Standard Time: UTC+05:30 all year
def ist_to_utc(text):
"""Turn 'DD-MM-YYYY HH:MM' in Indian time into ISO 8601 text in UTC."""
# Replace this line with your code.
return text
def business_days_between(start, end, holidays):
"""Count Monday-to-Friday days from start up to end (not included) that are not holidays."""
# Replace this line with your code.
return 0 The sample tests · test_when.py
from datetime import date
from when import business_days_between, ist_to_utc
def test_morning_in_india():
"""converts a morning in India to UTC"""
assert ist_to_utc("04-10-2026 09:30") == "2026-10-04T04:00:00+00:00"
def test_previous_day_in_utc():
"""moves times before 05:30 in India to the previous day, month and year in UTC"""
assert ist_to_utc("01-01-2027 03:00") == "2026-12-31T21:30:00+00:00"
assert ist_to_utc("01-03-2028 05:29") == "2028-02-29T23:59:00+00:00"
def test_late_evening():
"""keeps a late evening on the same day in UTC"""
assert ist_to_utc("31-03-2026 23:45") == "2026-03-31T18:15:00+00:00"
def test_rejects_other_forms():
"""raises ValueError for text that is not DD-MM-YYYY HH:MM"""
for text in ["2026-10-04 09:30", "31-04-2026 10:00", "04-10-2026"]:
try:
ist_to_utc(text)
except ValueError:
continue
assert False, f"expected a ValueError for {text!r}"
def test_one_week():
"""counts five working days in a week from Thursday to Thursday"""
assert business_days_between(date(2026, 10, 1), date(2026, 10, 8), []) == 5
def test_holidays():
"""skips holidays, and holidays at weekends change nothing"""
week = (date(2026, 10, 1), date(2026, 10, 8))
assert business_days_between(*week, [date(2026, 10, 2)]) == 4
assert business_days_between(*week, {date(2026, 10, 3), date(2026, 10, 4)}) == 5
def test_across_month_and_year_ends():
"""counts across the end of a month and of a year"""
assert business_days_between(date(2026, 1, 30), date(2026, 2, 3), []) == 2
assert business_days_between(date(2026, 12, 24), date(2027, 1, 4), [date(2026, 12, 25), date(2027, 1, 1)]) == 5
def test_empty_ranges():
"""returns 0 when the end is not after the start"""
assert business_days_between(date(2026, 10, 5), date(2026, 10, 5), []) == 0
assert business_days_between(date(2026, 10, 9), date(2026, 10, 5), []) == 0 A hint
datetime.strptime(text, "%d-%m-%Y %H:%M") gives a naive datetime. .replace(tzinfo=IST) says it is Indian time without changing the clock reading, .astimezone(UTC) converts it, and .isoformat() writes the text. For the working days, start with day = start, step with day += timedelta(days=1) while day < end, and count the days whose weekday() is below 5 (Monday is 0, Saturday 5) and that are not in holidays.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then test yourself on the seven ideas of this lesson. The first question comes from the idea you have answered least well so far, a right answer brings a harder one, and every choice is explained the moment you pick it:
Practice that adapts to you
Practise this lesson
19 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What is the difference between a naive and an aware datetime in Python?
A naive datetime has no time zone: its tzinfo is None, so 09:30 could be 09:30 in Kolkata, London or anywhere else,
and only the program knows which. An aware datetime carries a tzinfo that gives its offset from UTC, so it names one
exact moment. Aware values convert with astimezone() and compare correctly across zones; a naive and an aware value
are never equal, and ordering or subtracting them raises TypeError. When Python must convert a naive value, as in
timestamp(), it assumes the computer’s local time, which is how bugs appear when code moves to a server elsewhere.
Make values aware at the edge of the program, with datetime.now(UTC) or by attaching the right ZoneInfo, and keep
them aware.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A reminder app stores the next reminder as a UTC datetime and adds timedelta(hours=24) after sending it. London users who chose 09:00 get it at 10:00 from the last Sunday of March; users in India never complain. Explain both, fix the scheduling, and say what to do when a chosen time does not exist.
-
Fresher to mid
A shop in India stores the time of every order in UTC. Its daily sales report runs at 00:05 UTC and adds up the orders of the previous UTC day, and its totals never match the shop's till. Which orders land on the wrong day, how much of each day is affected, and how do you fix the query?
Key takeaways
date,timeanddatetimecheck their values when they are made and never change;replace()returns a changed copy.- A
timedeltaholds days, seconds and microseconds and no months, so step months withcalendar.monthrange()andmin(). - The format decides what a text means: parse day-first text with
%d-%m-%Y, always with a year, and usedate.strptime()on Python 3.14. - Naive datetimes have no zone:
replace(tzinfo=…)attaches one,astimezone()converts, anddatetime.now(UTC)replaces the deprecatedutcnow(). - Use
ZoneInfo("Asia/Kolkata")-style keys, never abbreviations such as IST, which name three offsets, and installtzdatawhere the system has no database; adding a timedelta keeps the wall clock, so do elapsed-time arithmetic in UTC and pick repeated hours withfold. - Store UTC as ISO 8601 with its offset, show local times, and measure durations with
time.monotonic().
Terms in this lesson
- aware datetime
- A datetime that carries a tzinfo giving its offset from UTC, so it names one exact moment. datetime.now(UTC) and a datetime made with tzinfo=ZoneInfo("Asia/Kolkata") are aware.
- daylight saving time
- Moving the clocks forward by an hour in spring and back in autumn, as the United Kingdom and the United States do. India does not use it, so Asia/Kolkata keeps +05:30 all year.
- fold (datetime)
- The datetime attribute that picks one of two moments when a clock reading happens twice, as when the clocks go back: fold=0, the default, is the first, and fold=1 the second.
- IANA time zone database
- The public record of the clock rules of every region, past and present, with keys such as Asia/Kolkata and Europe/London. zoneinfo reads it from the operating system or from the tzdata package.
- ISO 8601
- The international standard for writing dates and times year first, then month, day and time, with an offset such as +05:30 or Z for UTC. isoformat() writes it and fromisoformat() reads it.
- naive datetime
- A datetime with no time zone: its tzinfo is None, so it is a clock reading that could belong to any place. When Python must convert one, it assumes the computer's local time.
- UTC offset
- How far a clock is ahead of or behind UTC, such as +05:30 for India Standard Time. A zone with daylight saving time has two offsets a year: Europe/London is +00:00 in winter and +01:00 in summer.
References
- datetime, basic date and time types (Python Software Foundation)
- datetime: aware and naive objects (Python Software Foundation)
- datetime: strftime() and strptime() behavior (Python Software Foundation)
- zoneinfo, IANA time zone support (Python Software Foundation)
- What's New in Python 3.14: datetime (Python Software Foundation)
- calendar, general calendar-related functions (Python Software Foundation)
- time.monotonic() (Python Software Foundation)
- PEP 495: local time disambiguation (Python Software Foundation)
- tzdata, the IANA time zone database for Python (Python Software Foundation)
- Time Zone Database (Internet Assigned Numbers Authority)
- When do the clocks change? (GOV.UK)
- Daylight Saving Time Rules (National Institute of Standards and Technology)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress