Durations and Intervals

Date and Time in Practice

Chapter 7 ยท Durations and Intervals

So far a date or time has been a single point. Real programs mostly care about spans: how long something took, whether two bookings collide, which hours are free, how many events landed in each hour. This chapter covers the timedelta type in more detail, then builds the standard interval tools from a few lines each. Every snippet below was run with Python 3.14; the comments show what was printed.

What a timedelta Really Holds

A timedelta stores exactly three numbers: days, seconds and microseconds. Whatever you pass in is normalised into those, and the seconds and microseconds are always non-negative. That has one surprising consequence for negative durations:

from datetime import date, datetime, timedelta print(timedelta(days=1, hours=36)) # 2 days, 12:00:00 print(timedelta(hours=-1)) # -1 day, 23:00:00 print(timedelta(seconds=-1).days, timedelta(seconds=-1).seconds) # -1 86399 d = timedelta(days=2, hours=3, minutes=4, seconds=5) print(d.days, d.seconds, d.total_seconds()) # 2 11045 183845.0

Minus one hour is stored as "minus one day, plus 23 hours". It is correct, but printing it is confusing, and .seconds is not the total. The attributes .days, .seconds and .microseconds are the stored parts, not a breakdown for display. .seconds is never more than 86399, so it is not "the number of seconds" in a long duration. Use total_seconds() whenever you want one number.

Dividing and Formatting Durations

Dividing a timedelta by another timedelta tells you how many of one fit in the other. Floor division gives a whole count, modulo gives the remainder, and true division gives a float:

hour = timedelta(hours=1) print(d // hour, d % hour) # 51 0:04:05 print(d / hour) # 51.06805555555555 print(timedelta(hours=1) * 2.5, timedelta(hours=1) / 4) # 2:30:00 0:15:00 def hms(td): s = int(td.total_seconds()) sign = "-" if s < 0 else "" h, r = divmod(abs(s), 3600) m, s = divmod(r, 60) return f"{sign}{h}:{m:02d}:{s:02d}" print(hms(timedelta(days=1, hours=2, minutes=3, seconds=4))) # 26:03:04 print(hms(-timedelta(minutes=90))) # -1:30:00

The built-in str() switches to "N days, H:MM:SS" once a duration passes 24 hours, which suits logs but not a timer display. The helper above keeps counting hours and puts the sign at the front, so a negative duration reads sensibly. Note that int() truncates toward zero, so fractions of a second are dropped, not rounded.

The Limits

A timedelta holds at most 999999999 days, and a date only runs from year 1 to year 9999. Overflow raises an error instead of wrapping:

print(timedelta.max) # 999999999 days, 23:59:59.999999 try: date(9999, 12, 31) + timedelta(days=1) except OverflowError as e: print(e) # date value out of range try: timedelta(days=1_000_000_000) except OverflowError as e: print(e) # days=1000000000; must have magnitude <= 999999999

You will rarely hit these by accident, but "no end date" values are a common way to do it: a sentinel such as date.max plus a grace period will overflow. Treat an open-ended interval as None and test for it, rather than inventing a very distant date.

Do Two Intervals Overlap?

Represent an interval as a start and an end, and decide once whether the end is inclusive. The convention that avoids most bugs is half-open: the start is included and the end is not, exactly like range. Then a meeting from 09:00 to 10:00 and another from 10:00 to 11:00 touch but do not collide. Two intervals overlap exactly when each starts before the other ends:

def overlaps(a_start, a_end, b_start, b_end): return a_start < b_end and b_start < a_end t = lambda h, m=0: datetime(2026, 11, 2, h, m) print(overlaps(t(9), t(10), t(10), t(11))) # False (they only touch) print(overlaps(t(9), t(10, 30), t(10), t(11))) # True print(overlaps(t(9), t(12), t(10), t(11))) # True (one contains the other) def intersect(a, b): s, e = max(a[0], b[0]), min(a[1], b[1]) return (s, e) if s < e else None print(intersect((t(9), t(11)), (t(10), t(12)))) # (datetime(2026, 11, 2, 10, 0), datetime(2026, 11, 2, 11, 0)) print(intersect((t(9), t(10)), (t(10), t(12)))) # None

The test needs only two comparisons and no case analysis for "contains", "starts inside" or "ends inside". Both intervals must be the same kind of datetime: Chapter 3 showed that comparing naive and aware values raises an error, and for stored bookings the safe form is aware UTC values (Chapter 4).

Merging and Finding Free Time

Given many intervals, sort them by start and sweep once, extending the last interval whenever the next one begins at or before its end. Merging touching intervals as well as overlapping ones is usually what you want when counting booked time:

def merge(intervals): out = [] for s, e in sorted(intervals): if out and s <= out[-1][1]: out[-1] = (out[-1][0], max(out[-1][1], e)) else: out.append((s, e)) return out busy = [(t(13), t(14)), (t(9), t(10, 30)), (t(10), t(11)), (t(11), t(12)), (t(10, 15), t(10, 45))] for s, e in merge(busy): print(s.time(), e.time()) # 09:00:00 12:00:00 # 13:00:00 14:00:00 def free(busy, day_start, day_end): out, cur = [], day_start for s, e in merge(busy): if s > cur: out.append((cur, s)) cur = max(cur, e) if cur < day_end: out.append((cur, day_end)) return out for s, e in free([(t(9), t(10, 30)), (t(13), t(14))], t(9), t(17)): print(s.time(), e.time()) # 10:30:00 13:00:00 # 14:00:00 17:00:00

The max(out[-1][1], e) matters: a long interval can swallow shorter ones that follow, as the 10:15 to 10:45 entry shows. Dropping the max and using e would shrink the merged span and report phantom free time. Adding up the lengths of the unmerged intervals would also double-count overlaps, which is why you merge first and sum afterwards.

Generating Ranges

Python has no built-in date range, but a generator is three lines. Keep the same half-open rule so that consecutive ranges join without sharing a boundary:

def drange(start, stop, step): while start < stop: yield start start += step print([x.strftime("%H:%M") for x in drange(t(9), t(11), timedelta(minutes=45))]) # ['09:00', '09:45', '10:30'] print(len([date(2026, 11, 1) + timedelta(days=i) for i in range((date(2026, 12, 1) - date(2026, 11, 1)).days)])) # 30 print((date(2026, 11, 30) - date(2026, 11, 1)).days) # 29

The last two lines show the fence-post trap: the days from 1 to 30 November inclusive number 30, but the difference between the dates is 29. Subtracting two dates gives the distance, not the count of days on the calendar. If your range includes both ends, add one; if it is half-open, the difference is the count. Note that drange uses a fixed step, so over a daylight saving change it should be given naive local times or aware UTC times, as Chapter 5 explained, never aware local times with a "one day" step expected to mean the same wall-clock time.

Bucketing

Bucketing means rounding each timestamp down to the start of its slot (hour, 15 minutes, day) so that you can count or group by slot. Floor division of a timedelta by a timedelta does it neatly when measured from a fixed starting point:

from collections import Counter EPOCH = datetime(2000, 1, 1) def floor_to(dt, step): return EPOCH + ((dt - EPOCH) // step) * step now = datetime(2026, 11, 2, 10, 47, 31) print(floor_to(now, timedelta(minutes=15))) # 2026-11-02 10:45:00 print(floor_to(now, timedelta(hours=1))) # 2026-11-02 10:00:00 print(now.replace(minute=0, second=0, microsecond=0)) # 2026-11-02 10:00:00 events = [t(9, 5), t(9, 40), t(9, 59), t(10), t(10, 1), t(11, 30)] counts = Counter(floor_to(x, timedelta(hours=1)) for x in events) for k in sorted(counts): print(k.strftime("%H:%M"), counts[k]) # 09:00 3 # 10:00 2 # 11:00 1

Because every bucket is anchored to the same fixed instant, an event at exactly 10:00:00 lands in the 10:00 bucket and not the 09:00 one, which is the half-open rule again. This works for any step that divides evenly into whole days. Buckets measured in weeks depend on which weekday your anchor falls on (2000-01-01 was a Saturday), and months and years have no fixed length at all, so group those by (d.year, d.month) instead. Also bucket in UTC or in naive local wall-clock time; a one-hour step through an aware local time crosses the daylight saving traps from Chapter 5.

TaskToolWatch for
One number for a durationtotal_seconds().seconds is only the leftover part, under 86400
How many hours fittd // timedelta(hours=1)floor division rounds toward minus infinity, so negatives round down too
Do two spans collidea_start < b_end and b_start < a_enddecide whether the end is inclusive; prefer half-open
Total booked timemerge first, then sumsumming raw lengths double-counts overlaps
Open-ended intervalNone for "no end"a huge sentinel date can overflow
Group events by slotfloor_to(dt, step)weeks and months are not fixed lengths

Challenges

Exercise 1

On 2 November 2026 the meetings are 14:00-15:00, 09:00-10:30, 10:00-11:00, 11:00-11:30, 09:15-09:45 and 16:00-16:30. Write merge, print the merged blocks with their lengths, the total booked time, and the sum of the raw meeting lengths. Predict both totals by hand first and explain the difference.

๐Ÿ“„ View solution
Exercise 2

The working day is 09:00-17:00 and the busy blocks are 09:00-10:30, 10:00-11:00, 12:30-13:00 and 15:00-17:00. Write free_gaps and print each free gap with its length. Then write slots(gaps, length) that lists the start times of back-to-back 45-minute meetings that fit entirely inside a gap, and print them. Predict the slot list first.

๐Ÿ“„ View solution
Exercise 3

Write floor_to and hms. Count these events per 15-minute bucket: 09:05, 09:14:59, 09:15, 09:44, 10:00, 10:01 and 23:59:59. Then print the span between the earliest and latest event with str() and with hms, the negated span with hms, hms of minus one second, and the first event floored to a whole day. Predict which events share a bucket first.

๐Ÿ“„ View solution

Quick Reference

  • timedelta stores days, seconds and microseconds; use total_seconds() for one number
  • Negative durations print as "-1 day, 23:00:00"; format them yourself with a sign-first helper
  • Divide a timedelta by a timedelta: // whole count, % remainder, / float
  • Limits: 999999999 days for a timedelta, years 1 to 9999 for a date; overflow raises OverflowError
  • Half-open intervals (start in, end out) make touching spans not collide and ranges join cleanly
  • Overlap test: a_start < b_end and b_start < a_end
  • Merge: sort, sweep, extend with max of the ends; sum only after merging
  • Bucketing: EPOCH + ((dt - EPOCH) // step) * step; group months by year and month
  • Counting days: the difference of two dates is the distance; add one for an inclusive count
Coming next
Date and Time in Practice 8 looks at timestamps and epochs: Unix time, seconds versus milliseconds, the year 2038 limit and why leap seconds do not appear in Python.