In programming languages

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.

The safe default for recording when something happened is an aware UTC datetime: datetime.now(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

A naive and an aware datetime cannot be compared or subtracted β€” Python raises TypeError. The 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 behaviour.

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