← Debugging Guides

阅读中文版本

Python — TypeError: can't compare offset-naive and offset-aware datetimes

Fix the naive-versus-aware datetime TypeError, decide the single timezone convention for the codebase, and convert at the boundaries instead of patching comparisons.

beginnerdebuggingpythondatetimetimezone

Symptoms

  • TypeError: can't compare offset-naive and offset-aware datetimes when sorting, comparing, or subtracting timestamps
  • The failure appears only for records from one source while another source compares fine
  • Database rows parse as naive while API payloads or libraries return timezone-aware datetimes

Possible causes

  • One source produces naive datetimes (datetime.now(), strptime without %z) and another aware ones (datetime.now(timezone.utc), fromisoformat with offset)
  • A library or ORM returns naive values for columns without timezone type, mixed with aware values elsewhere
  • Comparisons or arithmetic happen between values from both sources in the same expression
  • A refactor added timezone.utc to some constructors but not all

Diagnosis steps

Classify each value

python -c "from datetime import datetime; a=datetime.now(); b=datetime.now(__import__('datetime').timezone.utc); print(a.tzinfo, b.tzinfo)"

Interpretation

tzinfo None means naive; a tzinfo object means aware — every failing operand can be labeled this way.

Trace the producing sources

grep -Rn "datetime.now(\|strptime(\|fromisoformat(\|utcfromtimestamp(" src/ | head -20

Interpretation

now() without args and strptime without %z are the naive factories; find which call sites feed the failing comparison.

Confirm the fix direction with a probe

python -c "from datetime import datetime,timezone; print(datetime.now(timezone.utc).astimezone())"

Interpretation

Choosing UTC-everywhere is validated by confirming astimezone conversions are the only boundary operations you need.

Root cause

A datetime with tzinfo carries an offset that comparison and subtraction must use; a naive datetime has none, so Python cannot know whether both operands express the same instant. Comparing them is ambiguous, so datetime raises TypeError instead of guessing.

Fix

Adopt one convention — aware UTC everywhere internally: construct with datetime.now(timezone.utc), parse with fromisoformat then attach tzinfo when the source is known UTC, and convert to local time only at display. Where a mixed boundary is unavoidable, normalize explicitly before comparing.

Risk note

Attaching timezone.utc to a value that was actually local time silently shifts instants by the offset. Verify the source semantics before assigning tzinfo.

Verify the fix

Run the failing sort or comparison across a dataset containing both sources; assert zero TypeErrors and that results match a reference computed in UTC.

Symptom walkthrough

The error names both classes in one line:

TypeError: can't compare offset-naive and offset-aware datetimes

Label every operand to find the odd one out:

for ts in [a, b, c]:
    print(ts, "naive" if ts.tzinfo is None else "aware")

The mixed population is the disease: database rows without a timezone column parse naive, while an API client or fromisoformat with an offset yields aware values. Only comparisons that mix the two populations explode, which is why some records sort fine and others do not — the error is data-dependent, not code-path-dependent.

Fix in practice

Standardize on aware UTC internally and convert at the edges:

from datetime import datetime, timezone

now = datetime.now(timezone.utc)          # never bare now()

# parsing a source that is documented as UTC:
ts = datetime.fromisoformat(value)
if ts.tzinfo is None:
    ts = ts.replace(tzinfo=timezone.utc)  # attach meaning explicitly

# display boundary only:
local = ts.astimezone()

Guard the boundary with a normalization helper so mixed data cannot reach comparison code:

def to_utc(ts: datetime) -> datetime:
    if ts.tzinfo is None:
        return ts.replace(tzinfo=timezone.utc)  # source is known UTC
    return ts.astimezone(timezone.utc)

The replace-with-UTC step deserves a comment naming the evidence that the source really is UTC — attaching UTC to local-time values shifts instants silently, which is worse than the TypeError you removed.