Arithmetic

whenever supports differences, additions, and subtractions across all its datetime and instant types. This page is a practical guide to those operations.

Tip

For the conceptual background on exact vs. calendar units, see the fundamentals. For working with duration objects directly, see delta types.

Simple examples

>>> ZonedDateTime("2023-12-28 11:30[Europe/Amsterdam]").add(hours=5, minutes=30)
ZonedDateTime("2023-12-28 17:00:00+01:00[Europe/Amsterdam]")

>>> Instant("2023-12-28 11:30Z") - ZonedDateTime(2023, 12, 28, tz="Europe/Amsterdam")
TimeDelta("PT12h30m")

>>> d1 = ZonedDateTime(2020, 1, 1, tz="Europe/Amsterdam")
>>> d2 = ZonedDateTime(2023, 6, 15, tz="Europe/Amsterdam")
>>> d2.since(d1, in_units=["years", "months", "days"])
ItemizedDelta("P3y5m14d")

Overview

The table below summarizes which operations are available for each type. Click a row heading to learn more about that kind of operation; click a cell to jump to that type’s detailed section.

Key: ✅ fully supported · ⚠️ supported with caveats · ❌ not supported

-/difference() vs. since()/until()

The - operator (and its method equivalent, difference()) always returns the exact elapsed time as a TimeDelta. It works between any two exact-time types (Instant, ZonedDateTime, OffsetDateTime), which may be mixed freely:

>>> d1 = ZonedDateTime(2020, 1, 1, tz="Europe/Amsterdam")
>>> d2 = ZonedDateTime(2023, 6, 15, tz="Europe/Amsterdam")
>>> d2 - d1
TimeDelta("PT30263h")

since() and until() are more flexible: you choose the units and get back either a float (with total=) or an ItemizedDelta (with in_units=):

>>> d2.since(d1, total="days")                          # float: calendar days
1261.0
>>> d2.since(d1, in_units=["years", "months", "days"])  # ItemizedDelta
ItemizedDelta("P3y5m14d")

until() is the direction-reversed counterpart of since(): a.until(b) is equivalent to b.since(a).

Both methods work with exact units (hours, minutes, seconds, nanoseconds) and calendar units (years, months, weeks, days). The - operator only returns exact elapsed time.

Exact vs. calendar units

This section explains what the rows in the overview table mean. For the specifics and caveats of each type, see the per-type sections below.

Exact units

Exact unitshours, minutes, seconds, nanoseconds, and sub-second variants — represent fixed durations on the global timeline. DST transitions never affect them: two hours is always two hours of real elapsed time:

>>> d = ZonedDateTime(2023, 3, 25, hour=12, tz="Europe/Amsterdam")
>>> d.add(hours=24)   # clocks spring forward overnight—local time shifts by 1 h
ZonedDateTime("2023-03-26 13:00:00+02:00[Europe/Amsterdam]")

PlainDateTime has no timezone context, so exact-unit operations emit a NaiveArithmeticWarning.

Calendar units

Calendar unitsyears, months, weeks, days — measure calendar distance and preserve the local time of day. By convention (RFC 5545), adding a day keeps the clock at the same time, even across a DST transition:

>>> d = ZonedDateTime(2023, 3, 25, hour=12, tz="Europe/Amsterdam")
>>> d.add(days=1)    # "same time tomorrow"—only 23 h elapsed due to DST
ZonedDateTime("2023-03-26 12:00:00+02:00[Europe/Amsterdam]")
>>> d.add(hours=24)  # exactly 24 hours later—local time shifts
ZonedDateTime("2023-03-26 13:00:00+02:00[Europe/Amsterdam]")
>>> d1 = ZonedDateTime(2020, 1, 1, tz="Europe/Amsterdam")
>>> d2 = ZonedDateTime(2023, 6, 15, tz="Europe/Amsterdam")
>>> d2.since(d1, in_units=["years", "months", "days"])
ItemizedDelta("P3y5m14d")

Month truncation. If the result falls on a day that doesn’t exist in a month, it is truncated to the last valid day:

>>> PlainDateTime(2023, 8, 31).add(months=1)
PlainDateTime("2023-09-30 00:00:00")   # September has 30 days

Various rounding modes are available for the smallest unit in since()/until(). See Rounding for details.

See also

the fundamentals for the full conceptual background on exact vs. calendar units.

Per type

Instant

Instant represents a single point in time with no calendar or timezone context. It only supports exact units: hours, minutes, seconds, and nanoseconds.

>>> i = Instant("2023-03-25T12:00Z")
>>> i.add(hours=24)
Instant("2023-03-26 12:00:00Z")
>>> i2 = Instant("2023-03-28 06:00Z")
>>> i2 - i
TimeDelta("PT66h")

years and months are not available; weeks and days can be treated as exact units, but emit a DaysAssumed24HoursWarning:

>>> i.add(days=1)                                # emits DaysAssumed24HoursWarning
Instant("2023-03-26 12:00:00Z")
>>> i.add(days=1, days_assumed_24h_ok=True)      # suppress
Instant("2023-03-26 12:00:00Z")

Becuase Instant has no calendar or timezone context, it doesn’t support since()/until(). Use in_units()/total() on the result of -/difference() instead:

>>> i2.difference(i).total("hours")
66.0
>>> i2.difference(i).in_units(["days", "hours"], days_assumed_24h_ok=True)
ItemizedDelta("P2dT18h")

ZonedDateTime

ZonedDateTime is the recommended type for all arithmetic. It carries full timezone rules and handles DST correctly — all four arithmetic operations are fully supported.

>>> d1 = ZonedDateTime(2020, 1, 1, tz="Europe/Amsterdam")
>>> d2 = ZonedDateTime(2023, 6, 15, tz="Europe/Amsterdam")
>>> d1.add(hours=5, minutes=30)
ZonedDateTime("2020-01-01 05:30:00+01:00[Europe/Amsterdam]")
>>> d2.since(d1, total="days")
1261.0
>>> d2.since(d1, in_units=["years", "months", "days"])
ItemizedDelta("P3y5m14d")

When using since()/until() with calendar units (years, months, weeks, days), both datetimes must share the same timezone — or a ValueError is raised. Exact units work freely across different timezones:

>>> tokyo = ZonedDateTime(2023, 6, 15, tz="Asia/Tokyo")
>>> d2.since(tokyo, total="hours")         # exact units: works across timezones
7.0
>>> d2.since(tokyo, total="days")          # calendar units: raises ValueError
Traceback (most recent call last):
  ...
ValueError: Calendar units can only be used to compare ZonedDateTimes with the same timezone

When adding calendar units, the result may land in a DST transition. Use disambiguate to control how this is resolved (default: "compatible"):

>>> d = ZonedDateTime(2024, 10, 3, 1, 15, tz="America/Denver")
>>> d.add(months=1)                          # default: compatible
ZonedDateTime("2024-11-03 01:15:00-06:00[America/Denver]")
>>> d.add(months=1, disambiguate="raise")
Traceback (most recent call last):
  ...
whenever.RepeatedTime: 2024-11-03 01:15:00 is repeated in timezone 'America/Denver'

The difference between days and hours is most visible during a DST transition:

>>> eve = ZonedDateTime(2025, 3, 30, hour=1, tz="Europe/Amsterdam")
>>> eve.add(days=1)    # "same time tomorrow"
ZonedDateTime("2025-03-31 01:00:00+02:00[Europe/Amsterdam]")
>>> eve.add(hours=24)  # exactly 24 hours later
ZonedDateTime("2025-03-31 02:00:00+02:00[Europe/Amsterdam]")

OffsetDateTime

OffsetDateTime carries a fixed UTC offset, not the full timezone rules needed to determine whether DST applies at a future point. All arithmetic operations are supported, but any operation that crosses a DST boundary may silently carry a stale offset. These operations emit a StaleOffsetWarning:

>>> d = OffsetDateTime(2024, 3, 9, 13, offset=-7)
>>> d.add(hours=24)                           # emits StaleOffsetWarning
OffsetDateTime("2024-03-10 13:00:00-07:00")   # offset is stale; Denver is -06:00 here
>>> d.assume_tz("America/Denver").add(hours=24)   # DST-safe alternative
ZonedDateTime("2024-03-10 14:00:00-06:00[America/Denver]")
>>> d.add(hours=24, stale_offset_ok=True)     # suppress if intentional
OffsetDateTime("2024-03-10 13:00:00-07:00")

For since()/until(), calendar units (years, months, weeks, days) require both datetimes to carry the same UTC offset — or a ValueError is raised. Exact units work freely across different offsets:

>>> d1 = OffsetDateTime("2024-06-01 10:00+00")    # 10:00 UTC
>>> d2 = OffsetDateTime("2024-06-01 14:00+02")    # 12:00 UTC
>>> d2.since(d1, total="hours")                   # exact units: works
2.0
>>> d2.since(d1, total="days")                    # calendar units: raises ValueError
Traceback (most recent call last):
  ...
ValueError: Calendar units can only be used to compare OffsetDateTimes with the same offset

Attention

Even in a timezone without DST, prefer ZonedDateTime for arithmetic. Political decisions can change a region’s UTC offset in the future.

Why allow operations that can be wrong?

DST-safe arithmetic requires full timezone rules. When you have an OffsetDateTime or PlainDateTime, that context isn’t available.

Rather than making these operations impossible—frustrating when you genuinely don’t have a timezone or know there is no DST—whenever allows them but emits a warning. The warning points to the safer alternative (ZonedDateTime) while leaving an escape hatch for cases where you understand the trade-off.

PlainDateTime

PlainDateTime has no timezone, so it cannot account for DST in exact-time operations. Calendar units (years, months, weeks, days) are fully supported without any caveats. Exact units — including the - operator and since()/until() with time-of-day units — emit NaiveArithmeticWarning:

>>> d1 = PlainDateTime(2023, 1, 1)
>>> d2 = PlainDateTime(2023, 4, 15)
>>> d2.since(d1, in_units=["months", "days"])           # calendar: no warning
ItemizedDelta("P3m14d")
>>> d2.since(d1, total="hours")                         # exact: NaiveArithmeticWarning
2496.0
>>> d2.since(d1, total="hours", naive_arithmetic_ok=True)  # suppress
2496.0
>>> d = PlainDateTime(2023, 10, 29, 1, 30)
>>> d.add(hours=2)                                # emits NaiveArithmeticWarning
PlainDateTime("2023-10-29 03:30:00")              # may not exist in your timezone
>>> d.assume_tz("Europe/Amsterdam").add(hours=2)  # timezone-aware alternative
ZonedDateTime("2023-10-29 02:30:00+01:00[Europe/Amsterdam]")
>>> d.add(hours=2, naive_arithmetic_ok=True)      # suppress if intentional
PlainDateTime("2023-10-29 03:30:00")