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.
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
| Code | Means | Example |
|---|---|---|
%Y / %y | year, 4 or 2 digits | 1990 / 90 |
%m / %B / %b | month number / name / short | 10 / October / Oct |
%d | day of month | 15 |
%A / %a | weekday name / short | Monday / Mon |
%H / %I | hour, 24 or 12 | 09 / 09 |
%M / %S | minute / second | 05 / 30 |
%p | AM or PM | AM |
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'
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:
- Store UTC. Always. Convert to local time only when displaying it to a human.
- Use aware datetimes for anything that crosses a machine boundary. Naive ones are fine for a stopwatch and dangerous for a calendar.
- 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.
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 monthsApproximate, and honest about it. If you need exact calendar arithmetic, that is what dateutil.relativedelta exists for.
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)))
10A 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.
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:00Try-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.