107 lines
4.9 KiB
Markdown
107 lines
4.9 KiB
Markdown
---
|
|
title: "Python — fromisoformat() Cannot Parse Z Suffix (Python < 3.11)"
|
|
aliases: [python-z-suffix, python-fromisoformat-z, js-iso-python-interop, python-utc-z]
|
|
tags: [python, javascript, datetime, interop, api, debugging]
|
|
sources:
|
|
- "daily/2026-04-24.md"
|
|
created: 2026-04-24
|
|
updated: 2026-04-24
|
|
---
|
|
|
|
# Python — fromisoformat() Cannot Parse Z Suffix (Python < 3.11)
|
|
|
|
JavaScript's `Date.toISOString()` always appends `Z` to ISO 8601 strings (e.g., `2026-04-24T11:00:00.000Z`). Python's `datetime.fromisoformat()` cannot parse the `Z` suffix in Python versions before 3.11 — it raises `ValueError`. This causes silent 500 errors whenever a JS frontend sends a date string to a Python backend that uses `fromisoformat()` for parsing.
|
|
|
|
## Key Points
|
|
|
|
- **JS `toISOString()` always outputs `Z` suffix** — there is no way to suppress this; every date sent from a browser will have `Z`
|
|
- **`datetime.fromisoformat("2026-04-24T11:00:00.000Z")` fails in Python < 3.11** — raises `ValueError: Invalid isoformat string`
|
|
- **Python 3.11+** fixed this: `fromisoformat()` now accepts `Z` as a valid UTC offset
|
|
- The failure is **silent in API endpoints** — the ValueError is caught by the framework as a 500, often with no log output visible in the response
|
|
- The filter/query parameters sent from JS date pickers are the most common trigger: `from=2026-04-01T00:00:00.000Z&to=2026-04-30T23:59:59.999Z`
|
|
|
|
## Details
|
|
|
|
### The Problem
|
|
|
|
```python
|
|
# Python 3.9 / 3.10 — FAILS
|
|
from datetime import datetime
|
|
datetime.fromisoformat("2026-04-24T11:00:00.000Z")
|
|
# ValueError: Invalid isoformat string: '2026-04-24T11:00:00.000Z'
|
|
|
|
# Python 3.11+ — WORKS
|
|
datetime.fromisoformat("2026-04-24T11:00:00.000Z")
|
|
# datetime(2026, 4, 24, 11, 0, tzinfo=timezone.utc)
|
|
```
|
|
|
|
The `Z` suffix is valid ISO 8601 for UTC, but Python's stdlib didn't support it in `fromisoformat()` until 3.11 (PEP 683).
|
|
|
|
### The Fix: Normalize Before Parsing
|
|
|
|
Replace `Z` with `+00:00` before passing to `fromisoformat()`:
|
|
|
|
```python
|
|
def _parse_iso(s: str) -> datetime:
|
|
"""Parse ISO string from JS toISOString() — handles Z suffix across all Python versions."""
|
|
if s.endswith("Z"):
|
|
s = s[:-1] + "+00:00"
|
|
return datetime.fromisoformat(s)
|
|
```
|
|
|
|
Or use a single-line approach:
|
|
|
|
```python
|
|
from datetime import datetime, timezone
|
|
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
```
|
|
|
|
### Why the Failure Is Silent
|
|
|
|
In a Quart/Flask/FastAPI endpoint:
|
|
```python
|
|
@app.route("/api/usage")
|
|
async def get_usage():
|
|
from_str = request.args.get("from")
|
|
from_dt = datetime.fromisoformat(from_str) # raises ValueError if Z suffix
|
|
...
|
|
```
|
|
|
|
The `ValueError` bubbles up as an unhandled exception → the framework catches it as a 500 Internal Server Error. The frontend receives a 500 with no body (or a generic error page). The filter appears to "not work" — but the API is actually crashing on date parsing.
|
|
|
|
This is especially insidious because:
|
|
1. The endpoint works in local dev if dates are typed manually without `Z`
|
|
2. The endpoint works in Python 3.11+ without any code change
|
|
3. The 500 may not appear in application logs if the exception handler swallows it
|
|
|
|
### Real Incident (2026-04-24)
|
|
|
|
Semblance admin dashboard period selector (React + TypeScript) sent dates via `new Date(startDate).toISOString()` as query parameters. Every request with a `from` or `to` date filter returned 500. The admin usage summary showed 0 rows. Root cause: Quart backend running Python 3.9, `datetime.fromisoformat()` rejecting `Z` suffix. Fix: `_parse_iso()` helper with `Z → +00:00` substitution.
|
|
|
|
### Period Filter Fallback Bug
|
|
|
|
The same endpoint had a related bug: when no `from`/`to` params were sent (meaning "All time"), the code defaulted to `_month_start()` — silently filtering to the current month instead of returning all records. The correct behavior for absent params is no date filter:
|
|
|
|
```python
|
|
def _period_match(from_str, to_str):
|
|
if not from_str and not to_str:
|
|
return {} # no filter — return all records
|
|
filters = {}
|
|
if from_str:
|
|
filters["created_at__gte"] = _parse_iso(from_str)
|
|
if to_str:
|
|
filters["created_at__lte"] = _parse_iso(to_str)
|
|
return filters
|
|
```
|
|
|
|
Both bugs together caused the "All time" view to show current-month data and the date filter to always 500.
|
|
|
|
## Related Concepts
|
|
|
|
- [[wiki/concepts/preflight-record-pattern]] — AI cost tracking that uses datetime for `effective_from`/`to` fields
|
|
- [[wiki/concepts/openai-max-completion-tokens]] — another silent API parameter mismatch that causes hard errors
|
|
- [[wiki/tech-patterns/fastapi-python-docker]] — FastAPI Python backend where date parsing happens at the API boundary
|
|
|
|
## Sources
|
|
|
|
- [[daily/2026-04-24.md]] — Semblance admin dashboard period filter returning 500 on all date-filtered requests; root cause was Python 3.9 `fromisoformat()` rejecting `Z` suffix from JS `toISOString()`; fix: `_parse_iso()` helper with `Z → +00:00` substitution; additional bug: missing params defaulted to current month instead of no filter
|