Compute durations and ages¶
The question. You need to answer "how long between these two moments?" or "how old is somebody born on this date?" — and the obvious approach (subtract and divide by 365) gives a wrong answer.
Two separate tools: timedelta for fixed durations (seconds, hours, days), and calendar-based arithmetic for things that depend on the calendar (months, years, ages). The canonical age-in-years function below is six lines and covers the whole corner.
from datetime import date, datetime, timedelta
# Fixed-duration arithmetic — subtraction gives a timedelta
start = datetime(2026, 4, 21, 9, 0)
end = datetime(2026, 4, 22, 15, 30)
gap = end - start
print('elapsed:', gap) # 1 day, 6:30:00
print('total seconds:', gap.total_seconds())
print('total hours: ', gap.total_seconds() / 3600)
# Age in whole years — calendar-based, not duration-based
def age_in_years(dob: date, as_of: date) -> int:
years = as_of.year - dob.year
# Subtract one if the birthday hasn't happened yet this year
if (as_of.month, as_of.day) < (dob.month, dob.day):
years -= 1
return years
print('age check — day before birthday:', age_in_years(date(2000, 6, 15), date(2026, 6, 14)))
print('age check — on birthday: ', age_in_years(date(2000, 6, 15), date(2026, 6, 15)))
print('age check — day after: ', age_in_years(date(2000, 6, 15), date(2026, 6, 16)))
# Variant: humanise a timedelta for display
from datetime import timedelta
def humanise(td: timedelta) -> str:
total = int(td.total_seconds())
days, rem = divmod(total, 86400)
hours, rem = divmod(rem, 3600)
minutes, _ = divmod(rem, 60)
parts = []
if days: parts.append(f'{days}d')
if hours: parts.append(f'{hours}h')
if minutes: parts.append(f'{minutes}m')
return ' '.join(parts) or '0m'
print(humanise(timedelta(days=1, hours=6, minutes=30)))
print(humanise(timedelta(minutes=45)))
print(humanise(timedelta(0)))
Why it works¶
Subtracting two datetimes produces a timedelta, which is a fixed duration in days, seconds, and microseconds. gap.days is the integer day component, not the total elapsed days: timedelta(hours=23).days is 0. That's why .total_seconds() is the escape hatch — divide by 86400 for total days as a float, by 3600 for total hours.
Age-in-years is not a timedelta problem. Calendar years aren't a fixed length — leap years, the Gregorian rules, and cultural definitions all vary. (as_of - dob).days / 365.25 is fine for bucketing cohorts ("average age ≈ 32"); it's wrong for the integer number of birthdays someone has had. The canonical approach is the one above: subtract the years directly, then adjust by one if the birthday is still to come. Five lines, no approximation.
Trade-offs¶
Months and years need relativedelta. timedelta(months=1) doesn't exist because the language won't guess whether you mean 28, 30, or 31 days. dateutil.relativedelta handles calendar shifts correctly, including end-of-month clamping: date(2026, 1, 31) + relativedelta(months=1) returns 2026-02-28, not 2026-03-03.
Business days need a specialised tool. If "days" means working days (excluding weekends and holidays), neither timedelta nor relativedelta helps. numpy.busday_count or pandas.tseries.offsets.BusinessDay with a holiday calendar is the right shape. For country-specific bank holidays, the holidays package has a table per country.
Human-readable durations need custom formatting. timedelta prints as 1 day, 6:30:00 — fine for logs, ugly for a UI. The humanise helper in the extra cell is enough for most cases; for locale-aware "2 hours ago" messages, reach for babel.dates.format_timedelta.
Related reading¶
- Avoid common datetime mistakes — including the "age in days / 365.25" bug you just saw.
- Convert between time zones — the time-zone-aware side of datetime work.
- UTC everywhere — the design rule that makes the rest easier.