Level 3 · Real Programs

Dates and Times 📅

Time looks simple and is not. This lesson gives you the 90% you need and warns you honestly about the 10% that has broken production systems at every company on earth.

The three types

from datetime import date, time, datetime

release = date(1990, 10, 15)
opening = time(9, 30)
launch = datetime(1990, 10, 15, 9, 30, 0)

print(release)
print(opening)
print(launch)
print(release.year, release.month, release.day)
print(launch.weekday(), launch.strftime("%A"))
1990-10-15
09:30:00
1990-10-15 09:30:00
1990 10 15
0 Monday

date is a day, time is a clock reading, datetime is both. weekday() counts from 0 for Monday, which is one of those arbitrary facts you look up forever.

Now

from datetime import datetime, date

today = date.today()
now = datetime.now()

print(type(today).__name__, type(now).__name__)
print(now.year >= 2024)
date datetime
True

This lesson mostly uses fixed dates rather than now(), for the same reason the school seeds its random numbers: an example whose output changes every day cannot be checked. That is also excellent advice for your own code, and Lesson 29 explains why testable code never calls now() deep inside a function.

Doing arithmetic with timedelta

from datetime import date, timedelta

release = date(1990, 10, 15)
sequel = date(1991, 12, 20)

gap = sequel - release
print(gap)
print(f"{gap.days} days, about {gap.days / 365.25:.1f} years")

print(release + timedelta(days=100))
print(release + timedelta(weeks=52))
print(release - timedelta(days=1))
431 days, 0:00:00
431 days, about 1.2 years
1991-01-23
1991-10-14
1990-10-14

Subtracting two dates gives a timedelta: a duration. Adding a timedelta to a date gives another date, and it handles month lengths and leap years for you, which is the entire reason not to do this arithmetic by hand.

📆 There is no timedelta(months=1)

Deliberately. How long is a month? Adding one month to 31 January has no single correct answer, and different businesses want different answers. If you need calendar months, use the third-party dateutil.relativedelta, and decide explicitly what your rule is.

Formatting: datetime to text

from datetime import datetime

launch = datetime(1990, 10, 15, 9, 5, 30)

print(launch.strftime("%Y-%m-%d"))
print(launch.strftime("%d/%m/%Y"))
print(launch.strftime("%A %d %B %Y"))
print(launch.strftime("%H:%M:%S"))
print(launch.strftime("%I:%M %p"))
print(launch.strftime("Released on %B %d, %Y at %H:%M"))
1990-10-15
15/10/1990
Monday 15 October 1990
09:05:30
09:05 AM
Released on October 15, 1990 at 09:05
CodeMeansExample
%Y / %yyear, 4 or 2 digits1990 / 90
%m / %B / %bmonth number / name / short10 / October / Oct
%dday of month15
%A / %aweekday name / shortMonday / Mon
%H / %Ihour, 24 or 1209 / 09
%M / %Sminute / second05 / 30
%pAM or PMAM

Parsing: text to datetime

from datetime import datetime, date

parsed = datetime.strptime("15/10/1990", "%d/%m/%Y")
print(parsed)          # no time in the input, so midnight

# ISO 8601 is the sane interchange format, and has its own shortcut
print(date.fromisoformat("1990-10-15"))
print(datetime.fromisoformat("1990-10-15T09:30:00"))
print(date(1990, 10, 15).isoformat())

try:
    datetime.strptime("banana", "%d/%m/%Y")
except ValueError as err:
    print("ValueError:", err)
1990-10-15 00:00:00
1990-10-15
1990-10-15 09:30:00
1990-10-15
ValueError: time data 'banana' does not match format '%d/%m/%Y'
ENCYCLOPEDIA[Medium: Success]

Always store and exchange dates as ISO 8601: 1990-10-15. It sorts correctly as plain text, it is unambiguous worldwide, and it is what every database and API expects.

The format 10/15/1990 versus 15/10/1990 has caused genuine medical and financial errors. On the third of April, half the world writes 03/04 and the other half writes 04/03, and neither half is warned.

Time zones, honestly

from datetime import datetime, timezone, timedelta

naive = datetime(1990, 10, 15, 9, 30)
aware = datetime(1990, 10, 15, 9, 30, tzinfo=timezone.utc)

print(naive, "<- no idea where in the world this is")
print(aware, "<- unambiguous")

melee_time = timezone(timedelta(hours=-5))
print(aware.astimezone(melee_time))

# comparing the two raises, which is Python protecting you
try:
    print(naive < aware)
except TypeError as err:
    print("TypeError:", err)
1990-10-15 09:30:00 <- no idea where in the world this is
1990-10-15 09:30:00+00:00 <- unambiguous
1990-10-15 04:30:00-05:00
TypeError: can't compare offset-naive and offset-aware datetimes

Three rules that will save you real pain:

  1. Store UTC. Always. Convert to local time only when displaying it to a human.
  2. Use aware datetimes for anything that crosses a machine boundary. Naive ones are fine for a stopwatch and dangerous for a calendar.
  3. Never write your own offset arithmetic. Daylight saving means some local times happen twice a year and some never happen at all. Python 3.9 added zoneinfo, which knows the real rules for every zone.
from datetime import datetime
from zoneinfo import ZoneInfo

utc = datetime(2026, 6, 15, 12, 0, tzinfo=ZoneInfo("UTC"))

for zone in ["Europe/London", "America/New_York", "Asia/Tokyo"]:
    local = utc.astimezone(ZoneInfo(zone))
    print(f"{zone:18} {local.strftime('%Y-%m-%d %H:%M %Z')}")
Europe/London      2026-06-15 13:00 BST
America/New_York   2026-06-15 08:00 EDT
Asia/Tokyo         2026-06-15 21:00 JST

Measuring how long something took

import time

start = time.perf_counter()
total = sum(range(1_000_000))
elapsed = time.perf_counter() - start

print(f"summed to {total:,}")
print(f"took less than a second: {elapsed < 1}")
summed to 499,999,500,000
took less than a second: True

Use time.perf_counter() for measuring durations, not datetime.now(): it is monotonic, so it cannot go backwards when the system clock is adjusted or the clocks change. Lesson 51 uses it properly with timeit.

Exercise 1

How old is this?

Write a function that takes a release date and a reference date and returns a friendly age like '35 years, 10 months'. Approximating months as 30.44 days is fine.

Reveal solution
from datetime import date


def age_of(released, today):
    """Return a friendly age string between two dates."""
    days = (today - released).days
    years = days // 365
    months = int((days % 365) / 30.44)
    return f"{years} years, {months} months"


print(age_of(date(1990, 10, 15), date(2026, 8, 17)))
print(age_of(date(2024, 1, 1), date(2026, 8, 17)))
35 years, 10 months
2 years, 7 months

Approximate, and honest about it. If you need exact calendar arithmetic, that is what dateutil.relativedelta exists for.

Exercise 2

Working days until

Count the weekdays (Monday to Friday) between two dates, excluding the start and including the end.

Reveal solution
from datetime import date, timedelta


def working_days(start, end):
    """Count Mon-Fri days after start, up to and including end."""
    days = 0
    current = start + timedelta(days=1)
    while current <= end:
        if current.weekday() < 5:
            days += 1
        current += timedelta(days=1)
    return days


print(working_days(date(2026, 8, 17), date(2026, 8, 31)))
10

A loop over days is perfectly acceptable here: two weeks is fourteen iterations. If you were doing this across ten years you would want maths instead of a loop, and that is a good instinct to develop.

Exercise 3

Parse a messy log

These timestamps arrive in three different formats. Normalise them all to ISO and sort them chronologically.

Reveal solution
from datetime import datetime

raw = [
    "15/10/1990 09:30",
    "1991-12-20T14:00:00",
    "Jan 03 1993 18:45",
]

formats = ["%d/%m/%Y %H:%M", "%Y-%m-%dT%H:%M:%S", "%b %d %Y %H:%M"]


def parse_any(text):
    """Try each known format until one works."""
    for fmt in formats:
        try:
            return datetime.strptime(text, fmt)
        except ValueError:
            continue
    raise ValueError(f"no known format matches {text!r}")


parsed = sorted(parse_any(t) for t in raw)
for moment in parsed:
    print(moment.isoformat())
1990-10-15T09:30:00
1991-12-20T14:00:00
1993-01-03T18:45:00

Try-each-format-until-one-works is the standard approach to messy real data, and raising a clear error when nothing matches is what stops the mess spreading silently into your database.

+100 XP