datetime.astimezone() on a Naive Value Silently Assumes Local Time (tested)

A datetime.datetime with no timezone attached is called "naive." We measured what happens when you call .astimezone() on a naive value that is really a UTC timestamp (a common shape coming out of a database or JSON API): Python assumes it's local system time instead, and converts from there — no exception, just a wrong answer.

1. A naive value that is really UTC, converted under TZ=America/New_York

from datetime import datetime, timezone

# Imagine this came from a DB column or JSON field that is documented as UTC,
# but the value itself carries no tzinfo:
naive_utc_value = datetime(2026, 6, 15, 12, 0, 0)  # really means 12:00 UTC

wrong = naive_utc_value.astimezone(timezone.utc)
print(wrong)
2026-06-15 16:00:00+00:00

Measured: run with TZ=America/New_York (UTC-4 in June), .astimezone(timezone.utc) turned 12:00 into 16:00 — a 4-hour shift. Per the docs, .astimezone() on a naive instance first assumes the value is in the system's local timezone, then converts to the target. Since the value was actually already UTC, that assumption is wrong, and the result is off by exactly the local UTC offset. No exception is raised either way.

2. Same code, different server timezone, different wrong answer

# Same naive_utc_value, same code, run on a box set to BST (+01:00) instead:
wrong_bst = naive_utc_value.astimezone(timezone.utc)
print(wrong_bst)
2026-06-15 11:00:00+00:00

Measured: identical input, identical code, but a different result (11:00 instead of 16:00) purely because the machine running it had a different local timezone set. This is what makes the bug easy to miss in testing: a dev laptop set to UTC would see .astimezone() do nothing visible, while the exact same code on a server in another timezone silently produces a different, wrong timestamp.

3. Fix: label the value's real timezone before converting

right = naive_utc_value.replace(tzinfo=timezone.utc).astimezone(timezone.utc)
print(right)
2026-06-15 12:00:00+00:00

Measured: calling .replace(tzinfo=timezone.utc) first attaches the correct, known timezone to the existing wall-clock numbers without changing them, then .astimezone() converts from the right starting point. The result matches the original 12:00, independent of the server's local timezone. The rule: .astimezone() only does the right thing on a datetime that already knows what timezone it's in; a naive value needs .replace(tzinfo=...) (not .astimezone()) to attach the correct one for the first time.

4. Why this is easy to ship by accident

Frequently asked questions

Does .astimezone() raise an error on a naive datetime?

No -- we measured it running silently and returning a value, just converted from the wrong assumed starting timezone (the system's local time) rather than erroring.

What timezone does Python assume for a naive datetime?

Per the datetime docs, .astimezone() on a naive instance assumes it represents the system's local time before converting. We confirmed this changes the result depending on the server's configured timezone.

How do I correctly convert a naive value that I know is UTC?

Use .replace(tzinfo=timezone.utc) first to attach the correct timezone without altering the numbers, then call .astimezone() if you need a different target zone. We confirmed this gives the correct result regardless of local system timezone.

Why did the same code give different results on two machines?

Because .astimezone() on a naive value uses the local system timezone as its starting assumption. We measured identical code producing different output purely from a different TZ environment setting.

Is this related to datetime.utcnow() being deprecated?

Yes -- utcnow() returns a naive value despite the name, which is exactly the shape that triggers this trap if it's later passed through .astimezone(). The docs recommend datetime.now(timezone.utc) instead, which returns an aware value from the start.

Written with AI assistance. Measured on 2026-10-04 with Python 3.13.5's built-in datetime module, running the same script under TZ=America/New_York and the system's default BST (+0100) to compare. Called .astimezone() on a naive datetime.datetime that was actually a UTC value, both directly and after .replace(tzinfo=timezone.utc), and compared results. PlainHeaders is our extension; Python's datetime module is built in.