python/cpython · error · ValueError

offset must be a timedelta strictly between -timedelta(hours

Error message

offset must be a timedelta strictly between -timedelta(hours=24) and timedelta(hours=24), not {offset!r}

What it means

Raised by _check_utc_offset() when utcoffset() or dst() returns a timedelta whose absolute value is not strictly less than one day (timedelta(1)). UTC offsets physically cannot reach ±24 hours, so datetime enforces -timedelta(hours=24) < offset < timedelta(hours=24). Note the strictness: exactly ±24h is also rejected.

Source

Thrown at Lib/_pydatetime.py:564

    if name is not None and not isinstance(name, str):
        raise TypeError("tzinfo.tzname() must return None or string, "
                        f"not {type(name).__name__!r}")

# name is the offset-producing method, "utcoffset" or "dst".
# offset is what it returned.
# If offset isn't None or timedelta, raises TypeError.
# If offset is None, returns None.
# Else offset is checked for being in range.
# If it is, its integer value is returned.  Else ValueError is raised.
def _check_utc_offset(name, offset):
    assert name in ("utcoffset", "dst")
    if offset is None:
        return
    if not isinstance(offset, timedelta):
        raise TypeError(f"tzinfo.{name}() must return None "
                        f"or timedelta, not {type(offset).__name__!r}")
    if not -timedelta(1) < offset < timedelta(1):
        raise ValueError("offset must be a timedelta "
                         "strictly between -timedelta(hours=24) and "
                         f"timedelta(hours=24), not {offset!r}")

def _check_date_fields(year, month, day):
    year = _index(year)
    month = _index(month)
    day = _index(day)
    if not MINYEAR <= year <= MAXYEAR:
        raise ValueError(f"year must be in {MINYEAR}..{MAXYEAR}, not {year}")
    if not 1 <= month <= 12:
        raise ValueError(f"month must be in 1..12, not {month}")
    dim = _days_in_month(year, month)
    if not 1 <= day <= dim:
        raise ValueError(f"day {day} must be in range 1..{dim} for month {month} in year {year}")
    return year, month, day

def _check_time_fields(hour, minute, second, microsecond, fold):
    hour = _index(hour)

View on GitHub (pinned to bc6749cc3b)

Solutions

  1. Fix the offset computation so |offset| < timedelta(hours=24)
  2. Check unit conversions (days*24 vs hours) in the tzinfo implementation
  3. Clamp pathological offsets in the tzinfo hook before returning

Example fix

// before
def utcoffset(self, dt):
    return timedelta(days=self.offset_hours)  # wrong unit
// after
def utcoffset(self, dt):
    return timedelta(hours=self.offset_hours)
Defensive patterns

Strategy: validation

Validate before calling

from datetime import timedelta

def safe_offset(off):
    if off is None:
        return None
    if not -timedelta(hours=24) < off < timedelta(hours=24):
        raise ValueError(f'offset {off} out of range')
    return off

Try / catch

try:
    dt.astimezone(tz)
except ValueError as e:
    if 'offset must be a timedelta' in str(e):
        raise  # buggy tzinfo implementation, fix it
    raise

Prevention

When it happens

Trigger: A custom tzinfo returning timedelta(hours=24) or timedelta(days=2) from utcoffset(); a bug computing offset as total days instead of hours; offsets accumulated across DST plus base offset exceeding 24h.

Common situations: Unit-conversion mistakes (days vs hours) in hand-rolled zoneinfo replacements; clamping or sign errors producing -timedelta(1); historical LMT offsets near ±24h.

Related errors


AI-assisted analysis of python/cpython@bc6749cc3b (2026-08-14). Data as JSON: /api/errors/10609ad52498a4f1. Report an issue: GitHub.