Python

datetime, the aware-vs-naive trap, and zoneinfo.

Python’s datetime module gives you four core types: date (calendar date only), time (time of day only), datetime (combined), and timedelta (a duration). Each datetime (and time) object is either naive — it carries no time-zone information, i.e. a bare civil time — or aware — it holds a tzinfo object that defines its zone or offset. This single flag is the source of most Python date bugs.

A second framing worth holding onto: date and datetime are not merely two precisions of one idea. A date names a period — a whole calendar day — while a datetime names a point. They are different kinds of value, which is exactly why a date and a datetime won’t compare or subtract directly (TypeError). See Points vs periods.

The safe default for recording when something happened is an aware UTC datetime: datetime.now(timezone.utc) (Python 3.11+ also exposes datetime.UTC as a short alias for timezone.utc). Avoid datetime.utcnow(): it looks like it returns UTC but actually returns a naive datetime — there is nothing in the object to indicate the zone. This footgun is so common that utcnow() was formally deprecated in Python 3.12.

For IANA time zones (e.g. "Europe/Paris"), use zoneinfo.ZoneInfo from the standard library (Python 3.9+). On earlier versions the third-party pytz library was the standard choice, but pytz requires calling .localize() instead of the constructor — using replace(tzinfo=...) with a pytz zone silently gives wrong results during DST transitions.

Aware vs naive

Ordering a naive against an aware datetime (<, >) or subtracting one from the other raises TypeError. Equality is sneakier: == does not raise — it simply returns False, so a naive and an aware datetime that denote the very same instant still compare as unequal. The other silent hazard is two naive datetimes that were meant to represent different zones: arithmetic succeeds but the answer is wrong.

Pitfall: datetime.utcnow() returns a naive datetime with no zone recorded. If it is stored, serialized, or passed to code that assumes local time, every downstream calculation is off by the server’s UTC offset — and the error changes across DST transitions.

Go deeper: zoneinfo, pytz, and DST-safe arithmetic

zoneinfo (PEP 615) ships in the standard library from Python 3.9 and uses the system’s IANA timezone database (or the tzdata PyPI package as a fallback on Windows). Create an aware datetime in a named zone:

from datetime import datetime
from zoneinfo import ZoneInfo

dt = datetime(2026, 3, 29, 1, 30, tzinfo=ZoneInfo("Europe/London"))

With pytz (pre-3.9 code), always use .localize():

import pytz
tz = pytz.timezone("Europe/London")
dt = tz.localize(datetime(2026, 3, 29, 1, 30))  # correct
# dt = datetime(2026, 3, 29, 1, 30, tzinfo=tz)  # WRONG — ignores DST rules

For parsing and serializing, datetime.fromisoformat() and .isoformat() are the idiomatic tools. Note that before Python 3.11, fromisoformat() did not accept the trailing Z suffix — use datetime.fromisoformat(s.replace("Z", "+00:00")) as a workaround on older runtimes.

Beware that timedelta arithmetic adds exact elapsed time, not civil time, even on a ZoneInfo-aware datetime: adding timedelta(days=1) adds exactly 24 hours, so across a spring-forward night the wall-clock reading lands an hour off “same time tomorrow”. To get civil “same wall-clock time the next day”, do the arithmetic on the naive wall-clock fields and re-attach the zone afterwards — (dt.replace(tzinfo=None) + timedelta(days=1)).replace(tzinfo=ZoneInfo("Europe/London")). ZoneInfo resolves the offset lazily from the wall-clock fields whenever the value is next used in an aware operation, so the re-attached datetime picks up the correct offset for its new date. (One caveat: the new wall-clock time can land in a DST gap or fold, where fold disambiguates — “same time tomorrow” may not exist or may be ambiguous.)

See also Instant vs civil time and Time zones vs offsets.

Canonical values

How date.fromisoformat / datetime.fromisoformat handle the shared canonical values. See also parsing user input for sloppy-input behavior.

Generated at build time · 3.11.2 · rendered in America/Los_Angeles

  • Handled losslessly — or correctly rejected a value this type should not accept.
  • Accepted, but lossy or surprising: precision truncated, offset discarded, or a silent assumption made.
  • Wrong or unsafe for the input — silently corrupted or invented information (a guessed or shifted value), or a hard rejection of a value that is valid under a standard the tool claims to support.

Date only

2026-06-12

Calendar date — no time, no zone

ISO 8601 not RFC 3339

  • stdlib datetime 2026-06-12
Go deeper: every method
stdlib datetime
date.fromisoformat(…) 2026-06-12
Parses to a plain calendar date — the type that matches the input.
datetime.fromisoformat(…) 2026-06-12 00:00:00
Accepts and pads with midnight, producing a naive datetime the string never specified.
→ America/Los_Angeles naive — no zone to convert
Naive — no zone to convert, so the requested conversion is a no-op.

Gotcha: per the ECMAScript spec a date-only string is parsed as midnight UTC, not local midnight. In any zone west of UTC, rendering it back as a local date shows the previous day. (Note the asymmetry with local date-time, which is parsed as local time.)

Local date-time

2026-06-12T12:34:56.123456789

Date and time, no zone

ISO 8601 not RFC 3339

  • stdlib datetime 2026-06-12 12:34:56.123456
Go deeper: every method
stdlib datetime
date.fromisoformat(…) ValueError: Invalid isoformat string: '2026-06-12T12:34:56.123456789'
Rejects the value — it carries a time, so it isn't a date.
datetime.fromisoformat(…) 2026-06-12 12:34:56.123456
Accepts, but truncates nanoseconds to microseconds (Python's max precision).
→ America/Los_Angeles naive — no zone to convert
Naive — no zone to convert, so the requested conversion is a no-op.

Note: with no zone, Date assumes browser-local time, so the absolute instant differs per reader. And Date stores only milliseconds — .123456789 truncates to .123; Temporal keeps nanoseconds.

Excess precision

2026-06-12T12:34:56.12345678987Z

11 fractional digits (beyond nanosecond), UTC

ISO 8601 RFC 3339

  • stdlib datetime 2026-06-12 12:34:56.123456+00:00
Go deeper: every method
stdlib datetime
date.fromisoformat(…) ValueError: Invalid isoformat string: '2026-06-12T12:34:56.12345678987Z'
Rejects the value — it carries a time, so it isn't a date.
datetime.fromisoformat(…) 2026-06-12 12:34:56.123456+00:00
Accepts but silently truncates the 11-digit fraction to microseconds.
→ America/Los_Angeles 2026-06-12T05:34:56.123456-07:00
Converts to the reference zone correctly, but at truncated microsecond precision.

Gotcha: 11 fractional digits exceed nanosecond resolution. Date silently truncates to milliseconds and Python to microseconds, so both accept a value they can't faithfully hold; Temporal refuses it outright.

With offset

2026-06-12T12:34:56.123456789+05:30

Date-time with explicit +05:30 offset

ISO 8601 RFC 3339

  • stdlib datetime 2026-06-12T00:04:56.123456-07:00
Go deeper: every method
stdlib datetime
date.fromisoformat(…) ValueError: Invalid isoformat string: '2026-06-12T12:34:56.123456789+05:30'
Rejects the value — it carries a time and offset, so it isn't a date.
datetime.fromisoformat(…) 2026-06-12 12:34:56.123456+05:30
Keeps the +05:30 offset, but truncates nanoseconds to microseconds.
→ America/Los_Angeles 2026-06-12T00:04:56.123456-07:00
Aware datetime — converts to the reference zone correctly (00:04 PDT).

Note: the +05:30 offset fixes the absolute instant, but it is not a time zone. Date keeps only the instant (offset gone); Temporal's PlainDateTime keeps the wall-clock fields and drops the offset, while Instant keeps the instant — pick the type that matches what you need.

UTC (Z)

2026-06-12T12:34:56.123456789Z

Date-time anchored to UTC via Z

ISO 8601 RFC 3339

  • stdlib datetime 2026-06-12T05:34:56.123456-07:00
Go deeper: every method
stdlib datetime
date.fromisoformat(…) ValueError: Invalid isoformat string: '2026-06-12T12:34:56.123456789Z'
Rejects the value — it carries a time and zone, so it isn't a date.
datetime.fromisoformat(…) 2026-06-12 12:34:56.123456+00:00
Parses the UTC instant, but truncates nanoseconds to microseconds.
→ America/Los_Angeles 2026-06-12T05:34:56.123456-07:00
Aware datetime — converts the UTC instant to the reference zone correctly.

Note: a clean UTC instant — the easy case. Both Date and Python still truncate the nanosecond fraction (to ms and µs respectively); only Temporal preserves all nine digits.

Space separator

2026-06-12 12:34:56Z

Space instead of T, UTC

not ISO 8601 RFC 3339

  • stdlib datetime 2026-06-12 12:34:56+00:00
Go deeper: every method
stdlib datetime
date.fromisoformat(…) ValueError: Invalid isoformat string: '2026-06-12 12:34:56Z'
Rejects the value — it carries a time, so it isn't a date.
datetime.fromisoformat(…) 2026-06-12 12:34:56+00:00
Parses the RFC 3339 space-separator form to the correct UTC instant.
→ America/Los_Angeles 2026-06-12T05:34:56-07:00
Aware datetime — converts to the reference zone correctly.

Note: the space separator is legal in RFC 3339 but not in ISO 8601, which requires a T. Every engine here accepts it anyway — a reminder that the two standards disagree at the edges.

Comma fraction

2026-06-12T12:34:56,123Z

Comma decimal sign, UTC

ISO 8601 not RFC 3339

  • stdlib datetime 2026-06-12 12:34:56.123000+00:00
Go deeper: every method
stdlib datetime
date.fromisoformat(…) ValueError: Invalid isoformat string: '2026-06-12T12:34:56,123Z'
Rejects the value — it carries a time, so it isn't a date.
datetime.fromisoformat(…) 2026-06-12 12:34:56.123000+00:00
Accepts the ISO 8601 comma decimal sign and parses the instant correctly.
→ America/Los_Angeles 2026-06-12T05:34:56.123000-07:00
Aware datetime — converts to the reference zone correctly.

Gotcha: ISO 8601 permits a comma as the decimal sign (56,123); RFC 3339 requires a dot. Date rejects the comma form outright, while Temporal and Python stdlib accept it — the boundary between the two standards in one character.

Unknown offset

2026-06-12T12:34:56-00:00

Negative-zero offset

not ISO 8601 RFC 3339

  • stdlib datetime 2026-06-12 12:34:56+00:00
Go deeper: every method
stdlib datetime
date.fromisoformat(…) ValueError: Invalid isoformat string: '2026-06-12T12:34:56-00:00'
Rejects the value — it carries a time and offset, so it isn't a date.
datetime.fromisoformat(…) 2026-06-12 12:34:56+00:00
Parses -00:00 as a fixed UTC+0 offset, dropping the RFC 3339 'unknown offset' nuance.
→ America/Los_Angeles 2026-06-12T05:34:56-07:00
Aware datetime — converts the (UTC-flattened) instant to the reference zone.

Gotcha: RFC 3339 uses -00:00 to mean “UTC, but the real local offset is unknown.” Every engine here collapses it to plain UTC +00:00, silently discarding that “unknown” nuance.

Lowercase t/z

2026-06-12t12:34:56z

Lowercase date-time designators

not ISO 8601 RFC 3339

  • stdlib datetime ValueError: Invalid isoformat string: '2026-06-12t12:34:56z'
Go deeper: every method
stdlib datetime
date.fromisoformat(…) ValueError: Invalid isoformat string: '2026-06-12t12:34:56z'
Rejects the value — it carries a time, so it isn't a date.
datetime.fromisoformat(…) ValueError: Invalid isoformat string: '2026-06-12t12:34:56z'
Raises ValueError on lowercase t/z — RFC 3339 case-insensitivity not implemented, an interop hazard.
→ America/Los_Angeles ValueError: Invalid isoformat string: '2026-06-12t12:34:56z'
No conversion possible because parsing already failed.

Gotcha: RFC 3339 says the t and z designators are case-insensitive. Date and Temporal accept the lowercase form; Python's fromisoformat rejects it — a real interop hazard for case-careless producers.

Basic format

20260612T123456Z

No separators (ISO basic), UTC

ISO 8601 not RFC 3339

  • stdlib datetime 2026-06-12 12:34:56+00:00
Go deeper: every method
stdlib datetime
date.fromisoformat(…) ValueError: Invalid isoformat string: '20260612T123456Z'
Rejects the value — it carries a time, so it isn't a date.
datetime.fromisoformat(…) 2026-06-12 12:34:56+00:00
Python 3.11+ fromisoformat accepts ISO basic format and parses the instant correctly.
→ America/Los_Angeles 2026-06-12T05:34:56-07:00
Aware datetime — converts to the reference zone correctly.

Gotcha: ISO 8601 “basic” format drops the separators (20260612T123456Z). Date returns Invalid Date and Python's date.fromisoformat rejects it, while datetime.fromisoformat and Temporal's Instant accept it — support is patchy.

Week date

2026-W24-3

ISO week-numbering date

ISO 8601 not RFC 3339

  • stdlib datetime 2026-06-10
Go deeper: every method
stdlib datetime
date.fromisoformat(…) 2026-06-10
Parses ISO week-date notation to the correct calendar date (2026-06-10).
datetime.fromisoformat(…) 2026-06-10 00:00:00
Accepts the week date and pads with midnight, producing a naive datetime not implied by the input.
→ America/Los_Angeles naive — no zone to convert
Naive — no zone to convert, so the requested conversion is a no-op.

Gotcha: the ISO week-numbering date 2026-W24-3 is valid ISO 8601 but unsupported by Date and Temporal (it isn't in RFC 9557). Python does parse it, to the calendar date 2026-06-10 — correct, but easy to overlook.

US slash + 12h

5/12/23 5pm

Human-typed US-style date and time

not ISO 8601 not RFC 3339 human-friendly

  • stdlib datetime ValueError: Invalid isoformat string: '5/12/23 5pm'
Go deeper: every method
stdlib datetime
date.fromisoformat(…) ValueError: Invalid isoformat string: '5/12/23 5pm'
Rejects the ambiguous human-typed input with ValueError.
datetime.fromisoformat(…) ValueError: Invalid isoformat string: '5/12/23 5pm'
Rejects the ambiguous human-typed input with ValueError.
→ America/Los_Angeles ValueError: Invalid isoformat string: '5/12/23 5pm'
No conversion possible because parsing already failed.

Gotcha: 5/12/23 5pm is human input, not a machine format — is that May 12 or December 5? Every engine here refuses it. Don't feed free-form text to a date constructor; parse it explicitly with a locale-aware library.

Spelled-out

June 5, 2026 5:00 PM

Human-typed, spelled-out month

not ISO 8601 not RFC 3339 human-friendly

  • stdlib datetime ValueError: Invalid isoformat string: 'June 5, 2026 5:00 PM'
Go deeper: every method
stdlib datetime
date.fromisoformat(…) ValueError: Invalid isoformat string: 'June 5, 2026 5:00 PM'
Rejects the spelled-out input with ValueError rather than guessing.
datetime.fromisoformat(…) ValueError: Invalid isoformat string: 'June 5, 2026 5:00 PM'
Rejects the spelled-out input with ValueError rather than guessing.
→ America/Los_Angeles ValueError: Invalid isoformat string: 'June 5, 2026 5:00 PM'
No conversion possible because parsing already failed.

Gotcha: JavaScript's Date happily guesses at June 5, 2026 5:00 PM — interpreting it in the runtime's local zone via implementation-defined heuristics. Temporal and Python reject it. A returned value here is more dangerous than an error.


← Back to all topics