Mathew K Analytics

Lesson 7 · Python Beginner Course

Python datetime Tutorial: Dates, Time Zones & Reminders (Beginner Python Course, Part 7)

In this lesson you'll be able to: Create and compare dates, times and datetimes with the datetime module Turn text into dates and dates into friendly text…

⬇ Download notebook

What you'll learn

Data

No separate download needed — the notebook creates or downloads everything it uses.

📓 Full notebook

Download .ipynb

Dates and Times: Calendars, Time Zones and Reminders#

In this lesson you'll be able to:

  • Create and compare dates, times and datetimes with the datetime module
  • Turn text into dates and dates into friendly text with strptime and strftime
  • Do date maths with timedelta: ages, countdowns and working days
  • Use the calendar module for month views, leap years and paydays
  • Handle time zones and daylight saving with zoneinfo, plus timestamps and timing code
  • Capstone: build a birthday and bill reminder planner that reads a CSV and shows the next 30 days

Warm-Up#

Counting days by hand works... until a month ends. Let's see it break, then fix it for good with Python's datetime module.

last_watered = {"Basil": 28, "Fern": 25, "Cactus": 2}   # day of the month
today_day = 30
for plant, day in last_watered.items():
    print(f"{plant:<7} watered {today_day - day} days ago")
Basil   watered 2 days ago
Fern    watered 5 days ago
Cactus  watered 28 days ago
# Now it's the 3rd of the next month. Plain numbers get it badly wrong:
today_day = 3
for plant, day in last_watered.items():
    print(f"{plant:<7} watered {today_day - day} days ago")
print("Negative days? We need real dates.")
Basil   watered -25 days ago
Fern    watered -22 days ago
Cactus  watered 1 days ago
Negative days? We need real dates.

The date Object#

A date is a page torn off a calendar: a year, a month and a day, nothing more. Python checks that the date is real and knows which weekday it falls on.

from datetime import date

launch = date(2026, 3, 14)
print(launch)
print(launch.year, launch.month, launch.day)
print(type(launch))
2026-03-14
2026 3 14
<class 'datetime.date'>
print(launch.weekday())      # Monday is 0, Sunday is 6
print(launch.isoweekday())   # Monday is 1, Sunday is 7

day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
print("Pi Day 2026 falls on a", day_names[launch.weekday()])
5
6
Pi Day 2026 falls on a Sat
real_today = date.today()   # this one changes every day you run it
print("Real today:", real_today)
print("Is it after Pi Day 2026?", real_today > launch)
Real today: 2026-10-15
Is it after Pi Day 2026? True
deadline = date(2026, 4, 1)
print(launch < deadline)
print("Later of the two:", max(launch, deadline))

appointments = [date(2026, 5, 2), date(2026, 1, 20), date(2026, 3, 9)]
print(sorted(appointments))
True
Later of the two: 2026-04-01
[datetime.date(2026, 1, 20), datetime.date(2026, 3, 9), datetime.date(2026, 5, 2)]
for appointment in sorted(appointments):
    print("Vet visit:", appointment)
Vet visit: 2026-01-20
Vet visit: 2026-03-09
Vet visit: 2026-05-02
moved = launch.replace(day=21)
print(launch, "->", moved)
print(date.fromisoformat("2026-07-04"))
2026-03-14 -> 2026-03-21
2026-07-04

Common mistake: a date that doesn't exist#

pay_day = date(2026, 2, 30)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[10], line 1
----> 1 pay_day = date(2026, 2, 30)

ValueError: day is out of range for month
def is_real_date(year, month, day):
    try:
        date(year, month, day)
        return True
    except ValueError:
        return False

print(is_real_date(2026, 2, 30), is_real_date(2026, 2, 28), is_real_date(2028, 2, 29))
False True True

Times and Datetimes#

A time is the clock on the wall. A datetime is the calendar page and the clock together.

from datetime import datetime, time

flight = datetime(2026, 7, 18, 6, 45)
print(flight)
print(flight.date(), "|", flight.time())
print("Boarding hour:", flight.hour, "minute:", flight.minute)
2026-07-18 06:45:00
2026-07-18 | 06:45:00
Boarding hour: 6 minute: 45
opening = time(7, 30)
closing = time(18, 0)
print("Cafe hours:", opening, "to", closing)

order_time = time(17, 55)
print("Open for this order?", opening <= order_time < closing)
Cafe hours: 07:30:00 to 18:00:00
Open for this order? True
def cafe_status(t):
    if t < opening:
        return "not open yet"
    if t >= closing:
        return "closed for the day"
    return "open, come in!"

for t in [time(6, 59), time(7, 30), time(12, 15), time(18, 0)]:
    print(t.strftime("%H:%M"), "->", cafe_status(t))
06:59 -> not open yet
07:30 -> open, come in!
12:15 -> open, come in!
18:00 -> closed for the day
dinner = datetime.combine(date(2026, 7, 18), time(19, 30))
print("Dinner booking:", dinner)
print("Same day as the flight?", dinner.date() == flight.date())
Dinner booking: 2026-07-18 19:30:00
Same day as the flight? True
now = datetime.now()   # changes every run
print("Right now:", now.replace(microsecond=0))
Right now: 2026-10-15 21:04:14
print(flight.isoformat())
print(datetime.fromisoformat("2026-07-18T06:45:00"))
2026-07-18T06:45:00
2026-07-18 06:45:00

Pro tip: store and share dates as ISO text (2026-07-18). It sorts correctly even as plain strings.

Formatting Dates with strftime#

strftime means string from time: you give a pattern, Python fills in the blanks.

Code Means Example
%d %m %Y day, month, year 05, 09, 2026
%A %a weekday name Saturday, Sat
%B %b month name September, Sep
%H:%M 24-hour clock 20:30
%I:%M %p 12-hour clock 08:30 PM
gig = datetime(2026, 9, 5, 20, 30)
print(gig.strftime("%d/%m/%Y"))
print(gig.strftime("%A %d %B %Y"))
print(gig.strftime("%H:%M"))
print(gig.strftime("%I:%M %p"))
05/09/2026
Saturday 05 September 2026
20:30
08:30 PM
formats = {"UK": "%d/%m/%Y", "US": "%m/%d/%Y", "ISO": "%Y-%m-%d",
           "Friendly": "%a %d %b", "Ticket": "%d %b %Y, doors %H:%M"}
for name, pattern in formats.items():
    print(f"{name:<9}{gig.strftime(pattern)}")
UK       05/09/2026
US       09/05/2026
ISO      2026-09-05
Friendly Sat 05 Sep
Ticket   05 Sep 2026, doors 20:30
print(f"Doors open at {gig:%H:%M} on {gig:%A}")
print(f"Ticket date: {gig:%d %b %Y}")
Doors open at 20:30 on Saturday
Ticket date: 05 Sep 2026
def nice_date(d):
    if 11 <= d.day <= 13:
        suffix = "th"
    else:
        suffix = {1: "st", 2: "nd", 3: "rd"}.get(d.day % 10, "th")
    return f"{d:%A} {d.day}{suffix} {d:%B}"

for day in [1, 2, 3, 11, 22, 23]:
    print(nice_date(date(2026, 9, day)))
Tuesday 1st September
Wednesday 2nd September
Thursday 3rd September
Friday 11th September
Tuesday 22nd September
Wednesday 23rd September

Quick check#

What does date(2026, 12, 25).strftime("%a %d/%m") print?

print(date(2026, 12, 25).strftime("%a %d/%m"))
Fri 25/12

Reading Dates from Text with strptime#

strptime is the reverse, string parse time: the pattern must match the text exactly.

text = "18/07/2026 06:45"
flight = datetime.strptime(text, "%d/%m/%Y %H:%M")
print(flight)
print(type(flight).__name__)
2026-07-18 06:45:00
datetime
receipt_dates = ["03/02/2026", "14/02/2026", "28/02/2026"]
parsed = [datetime.strptime(t, "%d/%m/%Y").date() for t in receipt_dates]
print(parsed)
print("First:", parsed[0], " Last:", parsed[-1])
[datetime.date(2026, 2, 3), datetime.date(2026, 2, 14), datetime.date(2026, 2, 28)]
First: 2026-02-03  Last: 2026-02-28
print(datetime.strptime("Sat 5 September 2026", "%a %d %B %Y").date())
print(datetime.strptime("8:15 PM", "%I:%M %p").time())
2026-09-05
20:15:00

Common mistake: a pattern that doesn't match the text#

datetime.strptime("2026-07-18", "%d/%m/%Y")
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[26], line 1
----> 1 datetime.strptime("2026-07-18", "%d/%m/%Y")

File /usr/lib/python3.13/_strptime.py:789, in _strptime_datetime(cls, data_string, format)
    786 def _strptime_datetime(cls, data_string, format="%a %b %d %H:%M:%S %Y"):
    787     """Return a class cls instance based on the input string and the
    788     format string."""
--> 789     tt, fraction, gmtoff_fraction = _strptime(data_string, format)
    790     tzname, gmtoff = tt[-2:]
    791     args = tt[:6] + (fraction,)

File /usr/lib/python3.13/_strptime.py:555, in _strptime(data_string, format)
    553 found = format_regex.match(data_string)
    554 if not found:
--> 555     raise ValueError("time data %r does not match format %r" %
    556                      (data_string, format))
    557 if len(data_string) != found.end():
    558     raise ValueError("unconverted data remains: %s" %
    559                       data_string[found.end():])

ValueError: time data '2026-07-18' does not match format '%d/%m/%Y'
def parse_any(text):
    for pattern in ["%d/%m/%Y", "%Y-%m-%d", "%d %b %Y"]:
        try:
            return datetime.strptime(text.strip(), pattern).date()
        except ValueError:
            pass
    return None

for t in ["18/07/2026", "2026-07-18", "18 Jul 2026", "July 18th"]:
    print(f"{t:<12} -> {parse_any(t)}")
18/07/2026   -> 2026-07-18
2026-07-18   -> 2026-07-18
18 Jul 2026  -> 2026-07-18
July 18th    -> None
def ask_date(prompt):
    while True:
        answer = input(prompt)
        found = parse_any(answer)
        if found:
            return found
        print(f"  Sorry, '{answer}' isn't a real date. Try 25/12/1990.")

birthday = ask_date("Your birthday (DD/MM/YYYY): ")
print("Got it:", nice_date(birthday), birthday.year)
Your birthday (DD/MM/YYYY): 31/02/1995
  Sorry, '31/02/1995' isn't a real date. Try 25/12/1990.
Your birthday (DD/MM/YYYY): 14/03/1995
Got it: Tuesday 14th March 1995

Date Maths with timedelta#

A timedelta is a length of time, like "14 days" or "8 hours 25 minutes". Dates move by adding them.

from datetime import timedelta

holiday = date(2026, 7, 18)
print("Back home:", holiday + timedelta(days=14))
print("Book by:  ", holiday - timedelta(weeks=6))
Back home: 2026-08-01
Book by:   2026-06-06
gap = date(2026, 12, 25) - date(2026, 7, 18)
print(gap)
print(gap.days, "days until Christmas")
print(type(gap).__name__)
160 days, 0:00:00
160 days until Christmas
timedelta
start = datetime(2026, 3, 9, 9, 15)
end = datetime(2026, 3, 9, 17, 40)
shift = end - start
print("Shift length:", shift)
print("In hours:", shift.total_seconds() / 3600)
Shift length: 8:25:00
In hours: 8.416666666666666
first_collection = date(2026, 1, 6)
print("Recycling bin days:")
for n in range(6):
    print(" ", (first_collection + timedelta(days=14 * n)).strftime("%a %d %b"))
Recycling bin days:
  Tue 06 Jan
  Tue 20 Jan
  Tue 03 Feb
  Tue 17 Feb
  Tue 03 Mar
  Tue 17 Mar
print(date(2026, 1, 31) + timedelta(days=30))   # crosses the end of February
print(date(2028, 2, 28) + timedelta(days=1))    # 2028 is a leap year
print(date(2026, 12, 31) + timedelta(days=1))   # happy new year
2026-03-02
2028-02-29
2027-01-01

Common mistake: adding a plain number to a date#

print(holiday + 14)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[34], line 1
----> 1 print(holiday + 14)

TypeError: unsupported operand type(s) for +: 'datetime.date' and 'int'
print(holiday + timedelta(days=14))
print(holiday + timedelta(days=1, hours=36))   # hours roll over into days, the date shows 2 days later
2026-08-01
2026-07-20

Ages, Countdowns and Working Days#

Most real date questions are one of three: how old, how long until, how many working days. We'll use a fixed "today" so the answers never change: Monday 9 March 2026.

TODAY = date(2026, 3, 9)

def age_on(born, on_day):
    years = on_day.year - born.year
    if (on_day.month, on_day.day) < (born.month, born.day):
        years -= 1   # birthday not reached yet this year
    return years

print(age_on(date(1995, 3, 14), TODAY))
print(age_on(date(1995, 3, 14), date(2026, 3, 14)))
30
31
def next_birthday(born, today):
    upcoming = born.replace(year=today.year)
    if upcoming < today:
        upcoming = upcoming.replace(year=today.year + 1)
    return upcoming

nb = next_birthday(birthday, TODAY)
print("Next birthday:", nice_date(nb), nb.year)
print("Days to go:", (nb - TODAY).days)
print("You'll be turning", age_on(birthday, nb))
Next birthday: Saturday 14th March 2026
Days to go: 5
You'll be turning 31
countdowns = {"Summer holiday": date(2026, 7, 18), "Gig": date(2026, 9, 5),
              "Christmas": date(2026, 12, 25), "Tax return": date(2027, 1, 31)}
for event, when in countdowns.items():
    days = (when - TODAY).days
    print(f"{event:<15}{days:>4} days  ({days // 7} weeks)")
Summer holiday  131 days  (18 weeks)
Gig             180 days  (25 weeks)
Christmas       291 days  (41 weeks)
Tax return      328 days  (46 weeks)
def working_days(start, end):
    count = 0
    day = start
    while day < end:
        if day.weekday() < 5:   # Mon to Fri
            count += 1
        day += timedelta(days=1)
    return count

print("Working days until the holiday:", working_days(TODAY, date(2026, 7, 18)))
Working days until the holiday: 95
bank_holidays = {date(2026, 4, 3), date(2026, 4, 6), date(2026, 5, 4)}

def add_working_days(start, n, holidays=()):
    day = start
    while n > 0:
        day += timedelta(days=1)
        if day.weekday() < 5 and day not in holidays:
            n -= 1
    return day

invoice = date(2026, 3, 30)
print("Pay within 10 working days:", add_working_days(invoice, 10))
print("Counting bank holidays:    ", add_working_days(invoice, 10, bank_holidays))
Pay within 10 working days: 2026-04-13
Counting bank holidays:     2026-04-15

Your turn#

  1. Work out your own age in days: (TODAY - your_birthday).days.
  2. Find how many working days are left in March 2026 from TODAY.

Mini-Project: The Plant Watering Calendar#

Back to the warm-up, this time with real dates. Each plant has a last watering day and a rhythm.

plants = {
    "Basil":  {"last": date(2026, 3, 7), "every": 2},
    "Fern":   {"last": date(2026, 3, 4), "every": 4},
    "Cactus": {"last": date(2026, 2, 20), "every": 21},
    "Orchid": {"last": date(2026, 3, 2), "every": 7},
}
print(len(plants), "plants on the windowsill")
4 plants on the windowsill
def next_watering(info):
    return info["last"] + timedelta(days=info["every"])

for name, info in plants.items():
    print(f"{name:<7} next due {next_watering(info):%a %d %b}")
Basil   next due Mon 09 Mar
Fern    next due Sun 08 Mar
Cactus  next due Fri 13 Mar
Orchid  next due Mon 09 Mar
def watering_status(info, today):
    days_left = (next_watering(info) - today).days
    if days_left < 0:
        return f"OVERDUE by {-days_left} day(s)"
    if days_left == 0:
        return "water today"
    return f"in {days_left} day(s)"

for name, info in plants.items():
    print(f"{name:<7} {watering_status(info, TODAY)}")
Basil   water today
Fern    OVERDUE by 1 day(s)
Cactus  in 4 day(s)
Orchid  water today
by_due = sorted(plants.items(), key=lambda pair: next_watering(pair[1]))
print("Watering order:", [name for name, info in by_due])
Watering order: ['Fern', 'Basil', 'Orchid', 'Cactus']
print(f"Watering plan for the week of {TODAY:%d %B}")
for offset in range(7):
    day = TODAY + timedelta(days=offset)
    thirsty = [n for n, i in plants.items() if (day - i["last"]).days % i["every"] == 0 and day > i["last"]]
    print(f"  {day:%a %d}: {', '.join(thirsty) or '-'}")
Watering plan for the week of 09 March
  Mon 09: Basil, Orchid
  Tue 10: -
  Wed 11: Basil
  Thu 12: Fern
  Fri 13: Basil, Cactus
  Sat 14: -
  Sun 15: Basil

The calendar Module#

Python ships with a whole wall calendar: month views, leap years and day names. Handy for paydays, bin days and anything that says "the last Friday of the month".

import calendar

print(calendar.month(2026, 12))
   December 2026
Mo Tu We Th Fr Sa Su
    1  2  3  4  5  6
 7  8  9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31

print("2026 leap year?", calendar.isleap(2026))
print("2028 leap year?", calendar.isleap(2028))
print("Leap years from 2000 to 2029:", calendar.leapdays(2000, 2030))
print("February 2026 (first weekday, days):", calendar.monthrange(2026, 2))
2026 leap year? False
2028 leap year? True
Leap years from 2000 to 2029: 8
February 2026 (first weekday, days): (calendar.SUNDAY, 28)
print(list(calendar.day_name))
print(calendar.month_name[12], "|", calendar.month_abbr[12])
['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
December | Dec
def last_weekday(year, month, weekday):
    days_in_month = calendar.monthrange(year, month)[1]
    day = date(year, month, days_in_month)
    while day.weekday() != weekday:
        day -= timedelta(days=1)
    return day

print("Payday is the last Friday of the month:")
for month in range(1, 7):
    print(f"  {calendar.month_abbr[month]}: {last_weekday(2026, month, calendar.FRIDAY):%d %b}")
Payday is the last Friday of the month:
  Jan: 30 Jan
  Feb: 27 Feb
  Mar: 27 Mar
  Apr: 24 Apr
  May: 29 May
  Jun: 26 Jun

Quick check#

How many Friday the 13ths are there in 2026? Guess, then run the cell.

unlucky = [date(2026, m, 13) for m in range(1, 13) if date(2026, m, 13).weekday() == calendar.FRIDAY]
print(len(unlucky), "of them:", [d.strftime("%B") for d in unlucky])
3 of them: ['February', 'March', 'November']

Time Zones with zoneinfo#

A datetime without a zone is naive: it doesn't know where on Earth it is. Add tzinfo and it becomes aware. Pro tip: on Windows, run pip install tzdata once so zoneinfo has its time zone database.

from zoneinfo import ZoneInfo

london = ZoneInfo("Europe/London")
call = datetime(2026, 7, 18, 15, 0, tzinfo=london)
print(call)
print(call.tzname(), call.utcoffset())
2026-07-18 15:00:00+01:00
BST 1:00:00
cities = {"New York": "America/New_York", "Tokyo": "Asia/Tokyo",
          "Sydney": "Australia/Sydney", "Nairobi": "Africa/Nairobi"}
print(f"Team call at {call:%H:%M} in London is:")
for city, zone in cities.items():
    local = call.astimezone(ZoneInfo(zone))
    print(f"  {city:<9}{local:%a %H:%M} ({local.tzname()})")
Team call at 15:00 in London is:
  New York Sat 10:00 (EDT)
  Tokyo    Sat 23:00 (JST)
  Sydney   Sun 00:00 (AEST)
  Nairobi  Sat 17:00 (EAT)
naive = datetime(2026, 7, 18, 15, 0)
print("Naive zone:", naive.tzinfo)
aware = naive.replace(tzinfo=london)
print("Aware zone:", aware.tzinfo, aware.utcoffset())
Naive zone: None
Aware zone: Europe/London 1:00:00

Common mistake: mixing naive and aware datetimes#

deadline = datetime(2026, 7, 18, 17, 0)
print(call < deadline)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[54], line 2
      1 deadline = datetime(2026, 7, 18, 17, 0)
----> 2 print(call < deadline)

TypeError: can't compare offset-naive and offset-aware datetimes
deadline = datetime(2026, 7, 18, 17, 0, tzinfo=london)
print("Call before the deadline?", call < deadline)
Call before the deadline? True
from datetime import timezone

server_time = datetime(2026, 7, 18, 14, 0, tzinfo=timezone.utc)
print("Server (UTC):", server_time)
print("London:      ", server_time.astimezone(london))
Server (UTC): 2026-07-18 14:00:00+00:00
London:       2026-07-18 15:00:00+01:00
zones = {"london": london, "new york": ZoneInfo("America/New_York"),
         "tokyo": ZoneInfo("Asia/Tokyo"), "sydney": ZoneInfo("Australia/Sydney")}
while True:
    city = input("Convert the call to which city? ").strip().lower()
    if city in zones:
        break
    print("  I know:", ", ".join(zones))
print(f"In {city.title()} the call is at {call.astimezone(zones[city]):%H:%M on %A}")
Convert the call to which city? Paris
  I know: london, new york, tokyo, sydney
Convert the call to which city? tokyo
In Tokyo the call is at 23:00 on Saturday

Your turn#

  1. Add Paris ("Europe/Paris") and your own city to the zones dictionary.
  2. Find a time on a weekday when it is between 09:00 and 17:00 in both London and New York.

Daylight Saving: When an Hour Goes Missing#

In the UK, clocks jump from 01:00 to 02:00 on 29 March 2026 and fall back on 25 October.

for when in [datetime(2026, 1, 15, 12, 0), datetime(2026, 7, 15, 12, 0)]:
    local = when.replace(tzinfo=london)
    print(f"{local:%d %b}: {local.tzname()}, UTC offset {local.utcoffset()}")
15 Jan: GMT, UTC offset 0:00:00
15 Jul: BST, UTC offset 1:00:00
bedtime = datetime(2026, 3, 28, 22, 0, tzinfo=london)
alarm = datetime(2026, 3, 29, 6, 0, tzinfo=london)
print("Looks like:", alarm - bedtime, "of sleep")

real = alarm.astimezone(timezone.utc) - bedtime.astimezone(timezone.utc)
print("Really got:", real, "of sleep")
Looks like: 8:00:00 of sleep
Really got: 7:00:00 of sleep
print("Bedtime in UTC:", bedtime.astimezone(timezone.utc).strftime("%H:%M"))
print("Alarm in UTC:  ", alarm.astimezone(timezone.utc).strftime("%H:%M"))
Bedtime in UTC: 22:00
Alarm in UTC:   05:00

Pro tip: do your maths in UTC, and convert to local time only when you show it to a person.

Quick check#

What will tzname() say for London on 1 November 2026: GMT or BST?

print(datetime(2026, 11, 1, 12, 0, tzinfo=london).tzname())
GMT

Timestamps: Time as One Big Number#

A timestamp counts seconds since 1 January 1970 (UTC). Apps, logs and databases love them. It's the same number everywhere on Earth, so turn it into local time only when you display it.

print(datetime.fromtimestamp(0, tz=timezone.utc))
moment = datetime(2026, 7, 18, 12, 0, tzinfo=timezone.utc)
print(moment.timestamp())
1970-01-01 00:00:00+00:00
1784376000.0
import time

print("Seconds since 1970, right now:", round(time.time()))   # changes every run
Seconds since 1970, right now: 1792060455
workout_log = [1784358000, 1784361720, 1784444400, 1784447100]   # from a fitness watch
for stamp in workout_log:
    when = datetime.fromtimestamp(stamp, tz=london)
    print(stamp, "->", when.strftime("%a %d %b %H:%M"))
1784358000 -> Sat 18 Jul 08:00
1784361720 -> Sat 18 Jul 09:02
1784444400 -> Sun 19 Jul 08:00
1784447100 -> Sun 19 Jul 08:45
sessions = [(workout_log[0], workout_log[1]), (workout_log[2], workout_log[3])]
for start, end in sessions:
    minutes = (end - start) / 60
    print(f"Workout on {datetime.fromtimestamp(start, tz=london):%A}: {minutes:.0f} minutes")
Workout on Saturday: 62 minutes
Workout on Sunday: 45 minutes

Pausing and Timing: time.sleep and time.perf_counter#

sleep is a pause button. perf_counter is a stopwatch.

for n in [3, 2, 1]:
    print(n, "...")
    time.sleep(0.3)
print("Kettle's on!")
3 ...
2 ...
1 ...
Kettle's on!
start = time.perf_counter()
total = sum(range(1_000_000))
elapsed = time.perf_counter() - start
print("Total:", total)
print(f"Took {elapsed:.4f} seconds (your number will differ)")
Total: 499999500000
Took 0.0148 seconds (your number will differ)
def stopwatch(label, task):
    start = time.perf_counter()
    task()
    took = time.perf_counter() - start
    print(f"{label:<14}{took * 1000:8.2f} ms")
    return took

nap = stopwatch("short nap", lambda: time.sleep(0.25))
print("Close to 0.25 s?", abs(nap - 0.25) < 0.1)
short nap       250.23 ms
Close to 0.25 s? True
guest_list = [f"guest{n}" for n in range(200_000)]
guest_set = set(guest_list)
t_list = stopwatch("list lookup", lambda: "guest199999" in guest_list)
t_set = stopwatch("set lookup", lambda: "guest199999" in guest_set)
print("The set was faster:", t_set < t_list)
list lookup       2.06 ms
set lookup        0.00 ms
The set was faster: True

Pro tip: use perf_counter for measuring how long code takes. datetime.now() can jump if the computer's clock is adjusted.

Your turn#

  1. Write a 5-second countdown that prints the seconds remaining each time it sleeps.
  2. Time how long it takes to sort a list of 100,000 shuffled numbers.

Capstone: The Birthday and Bill Reminder Planner#

Everything together: a CSV of events, parsing, date maths, calendar, a time zone and a stopwatch. Our fixed "today" is Thursday 10 December 2026, so the window crosses New Year.

Step 1: Save and load the events#

One row per event. repeat is yearly, monthly or once; bills also have an amount in dollars.

from pathlib import Path

folder = Path("lesson_files")
folder.mkdir(exist_ok=True)
events_csv = folder / "events.csv"
events_csv.write_text(
    "name,kind,date,repeat,amount\n"
    "Mum,birthday,1961-12-14,yearly,\n"
    "Leo,birthday,2019-01-03,yearly,\n"
    "Priya,birthday,1994-06-21,yearly,\n"
    "Grandad,birthday,1948-12-25,yearly,\n"
    "Amara,birthday,1990-12-28,yearly,\n"
    "Rent,bill,2026-01-01,monthly,850.00\n"
    "Phone,bill,2026-01-18,monthly,22.50\n"
    "Gym,bill,2026-01-31,monthly,35.00\n"
    "Car insurance,bill,2026-01-05,yearly,410.00\n"
    "TV licence,bill,2026-12-20,once,174.50\n"
    "Dentist,appointment,2026-12-10,once,\n"
    "Passport photos,appointment,2026-11-30,once,\n"
)
print(len(events_csv.read_text().splitlines()) - 1, "events saved to", events_csv)
12 events saved to lesson_files/events.csv
import csv

with open(events_csv, newline="") as f:
    rows = list(csv.DictReader(f))
print(rows[0])
print(rows[5])
{'name': 'Mum', 'kind': 'birthday', 'date': '1961-12-14', 'repeat': 'yearly', 'amount': ''}
{'name': 'Rent', 'kind': 'bill', 'date': '2026-01-01', 'repeat': 'monthly', 'amount': '850.00'}
from dataclasses import dataclass

@dataclass
class Event:
    name: str
    kind: str
    start: date
    repeat: str
    amount: float = 0.0

events = [Event(r["name"], r["kind"], date.fromisoformat(r["date"]), r["repeat"],
                float(r["amount"] or 0)) for r in rows]
print(events[6])
Event(name='Phone', kind='bill', start=datetime.date(2026, 1, 18), repeat='monthly', amount=22.5)

Step 2: When is each event next due?#

Birthdays come round every year, bills every month. Careful: not every month has a 31st.

def same_day_in_month(day, year, month):
    last_day = calendar.monthrange(year, month)[1]
    return date(year, month, min(day, last_day))   # 31st becomes 30th or 28th

print(same_day_in_month(31, 2027, 2))
print(same_day_in_month(31, 2026, 12))
2027-02-28
2026-12-31
def next_occurrence(event, today):
    if event.repeat == "once":
        return event.start if event.start >= today else None
    if event.repeat == "yearly":
        return next_birthday(event.start, today)
    when = same_day_in_month(event.start.day, today.year, today.month)
    if when < today:
        year, month = (today.year + 1, 1) if today.month == 12 else (today.year, today.month + 1)
        when = same_day_in_month(event.start.day, year, month)
    return when

print("Rent from 9 March 2026:", next_occurrence(Event("Rent", "bill", date(2026, 1, 1), "monthly"), TODAY))
Rent from 9 March 2026: 2026-04-01
PLANNER_TODAY = date(2026, 12, 10)
for e in events[5:8] + events[10:]:
    print(f"{e.name:<16}{e.repeat:<8} -> {next_occurrence(e, PLANNER_TODAY)}")
Rent            monthly  -> 2027-01-01
Phone           monthly  -> 2026-12-18
Gym             monthly  -> 2026-12-31
Dentist         once     -> 2026-12-10
Passport photos once     -> None

Step 3: The 30-day report#

Keep only what falls inside the window, sort it by date, then print one friendly line per reminder.

def due_within(events, today, days=30):
    window_end = today + timedelta(days=days)
    due = []
    for e in events:
        when = next_occurrence(e, today)
        if when is not None and when <= window_end:
            due.append((when, e))
    return sorted(due, key=lambda pair: pair[0])

coming_up = due_within(events, PLANNER_TODAY)
print(len(coming_up), "things in the next 30 days")
10 things in the next 30 days
def describe_gap(days):
    if days == 0:
        return "TODAY"
    if days == 1:
        return "tomorrow"
    return f"in {days} days"

print([describe_gap(n) for n in [0, 1, 12]])
['TODAY', 'tomorrow', 'in 12 days']
def reminder_line(when, e, today):
    gap = describe_gap((when - today).days)
    line = f"{when:%a %d %b}  {gap:<12}{e.name:<14}"
    if e.kind == "birthday":
        line += f"turns {age_on(e.start, when)}"
    elif e.kind == "bill":
        line += f"${e.amount:,.2f}"
        if when.weekday() >= 5:
            line += "  (weekend: pay Friday)"
    else:
        line += e.kind
    return line

print(reminder_line(*coming_up[0], PLANNER_TODAY))
Thu 10 Dec  TODAY       Dentist       appointment
def print_report(events, today, days=30):
    due = due_within(events, today, days)
    print(f"=== Next {days} days from {today:%A %d %B %Y} ===")
    for when, e in due:
        print(reminder_line(when, e, today))
    bills = sum(e.amount for when, e in due if e.kind == "bill")
    print(f"--- {len(due)} reminders, ${bills:,.2f} of bills to pay ---")

print_report(events, PLANNER_TODAY)
=== Next 30 days from Thursday 10 December 2026 ===
Thu 10 Dec  TODAY       Dentist       appointment
Mon 14 Dec  in 4 days   Mum           turns 65
Fri 18 Dec  in 8 days   Phone         $22.50
Sun 20 Dec  in 10 days  TV licence    $174.50  (weekend: pay Friday)
Fri 25 Dec  in 15 days  Grandad       turns 78
Mon 28 Dec  in 18 days  Amara         turns 36
Thu 31 Dec  in 21 days  Gym           $35.00
Fri 01 Jan  in 22 days  Rent          $850.00
Sun 03 Jan  in 24 days  Leo           turns 8
Tue 05 Jan  in 26 days  Car insurance $410.00
--- 10 reminders, $1,492.00 of bills to pay ---

Step 4: Extras#

Weekly bill totals, a marked calendar, a call to a friend abroad, a saved file and a stopwatch.

print("Bills by ISO week:")
weekly = {}
for when, e in coming_up:
    if e.kind == "bill":
        week = when.isocalendar().week
        weekly[week] = weekly.get(week, 0) + e.amount
for week, total in weekly.items():
    print(f"  Week {week:>2}: ${total:>8,.2f}")
Bills by ISO week:
  Week 51: $  197.00
  Week 53: $  885.00
  Week  1: $  410.00
marked = {when.day for when, e in coming_up if when.month == 12}
print("December 2026  (* = something due)")
print("".join(f"{name:>3} " for name in ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]))
for week in calendar.monthcalendar(2026, 12):
    print("".join("    " if d == 0 else f"{d:>3}{'*' if d in marked else ' '}" for d in week))
December 2026  (* = something due)
 Mo  Tu  We  Th  Fr  Sa  Su 
      1   2   3   4   5   6 
  7   8   9  10* 11  12  13 
 14* 15  16  17  18* 19  20*
 21  22  23  24  25* 26  27 
 28* 29  30  31*            
sydney = ZoneInfo("Australia/Sydney")
amara_bday = next_occurrence(events[4], PLANNER_TODAY)
call_sydney = datetime(amara_bday.year, amara_bday.month, amara_bday.day, 9, 0, tzinfo=sydney)
call_london = call_sydney.astimezone(london)
print(f"Amara lives in Sydney. 9am on her birthday there is")
print(f"{call_london:%A %d %B at %H:%M} in London ({call_london.tzname()})")
Amara lives in Sydney. 9am on her birthday there is
Sunday 27 December at 22:00 in London (GMT)
def ask_days():
    while True:
        answer = input("How many days ahead should I look? ")
        if answer.isdigit() and 1 <= int(answer) <= 366:
            return int(answer)
        print("  Please type a whole number of days, like 14.")

days = ask_days()
print_report(events, PLANNER_TODAY, days)
How many days ahead should I look? a month
  Please type a whole number of days, like 14.
How many days ahead should I look? 21
=== Next 21 days from Thursday 10 December 2026 ===
Thu 10 Dec  TODAY       Dentist       appointment
Mon 14 Dec  in 4 days   Mum           turns 65
Fri 18 Dec  in 8 days   Phone         $22.50
Sun 20 Dec  in 10 days  TV licence    $174.50  (weekend: pay Friday)
Fri 25 Dec  in 15 days  Grandad       turns 78
Mon 28 Dec  in 18 days  Amara         turns 36
Thu 31 Dec  in 21 days  Gym           $35.00
--- 7 reminders, $232.00 of bills to pay ---
reminders_file = folder / "reminders.txt"
lines = [reminder_line(when, e, PLANNER_TODAY) for when, e in due_within(events, PLANNER_TODAY)]
reminders_file.write_text("\n".join(lines) + "\n")
print("Saved", len(lines), "reminders to", reminders_file)
print(reminders_file.read_text().splitlines()[0])
Saved 10 reminders to lesson_files/reminders.txt
Thu 10 Dec  TODAY       Dentist       appointment
start = time.perf_counter()
for offset in range(365):
    due_within(events, PLANNER_TODAY + timedelta(days=offset))
took = time.perf_counter() - start
print(f"Planned a whole year of reminders in {took:.3f} seconds (yours will differ)")
Planned a whole year of reminders in 0.005 seconds (yours will differ)

Recap#

  • date, time and datetime objects; weekday(), replace(), combine() and ISO format
  • strftime turns dates into text; strptime turns text into dates (the pattern must match)
  • timedelta for adding, subtracting and measuring gaps; total_seconds()
  • Ages, countdowns and working days with small, testable functions
  • calendar: month views, isleap, monthrange, day and month names
  • zoneinfo: aware vs naive datetimes, astimezone, daylight saving, UTC for maths
  • Timestamps with timestamp() and fromtimestamp()
  • time.sleep to pause and time.perf_counter to measure

Try it yourself#

  1. Add a weekly repeat type (bin day, gym class) to next_occurrence and the CSV.
  2. Handle 29 February birthdays: in non-leap years, celebrate on 28 February.
  3. Let each event have its own time zone column, and show reminders in both local and London time.

What's next#

Lesson 8: Automate Your Files and Folders. Put Python to work tidying folders, renaming files in bulk and making timestamped backups.

Found this useful?

All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.