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
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
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
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
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
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
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
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
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
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
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
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
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
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.