Zum Inhalt springen
jsonbeautifiers
Deutsch

JSON hat keinen Datumstyp, also wählen Sie einen und schreiben Sie ihn auf

JSON hat sechs Typen und keiner davon ist ein Datum, also erfindet jede Codebasis einen. Von den vier üblichen Antworten ist nur eine sicher.

Jede Aussage auf dieser Seite ist entweder gemessen oder belegt. Wo sie keines von beidem ist, steht das dabei.

Jemand meldet, dass jeder Datensatz in der Admin-Tabelle am 21. Januar 1970 angelegt wurde. Das Payload sieht in Ordnung aus:

{ "created_at": 1735689600 }

Das ist der 1. Januar 2025 in Epoch-Sekunden. Das Frontend hat new Date(1735689600) gemacht, was Millisekunden erwartet, also las es 1.735.689 Sekunden nach der Epoche und landete drei Wochen in 1970. Nichts wurde geworfen. Die Zahl war gültig, der Typ war korrekt, und die Bedeutung ging zwischen zwei Diensten verloren, weil JSON keine Möglichkeit hat, sie zu transportieren.

RFC 8259 gibt Ihnen Objekte, Arrays, Zeichenketten, Zahlen, Wahrheitswerte und null. Ein Datum gibt es nicht. Was Sie senden, ist eine Zeichenkette oder eine Zahl, deren Deutung beide Seiten privat vereinbart haben, und diese Vereinbarung lebt in der Dokumentation, oder in niemandes Kopf.

Die vier Konventionen, denen Sie begegnen werden

Konvention Beispiel Was es ist
RFC-3339-Zeichenkette "2025-01-01T00:00:00Z" Selbstbeschreibend, sortierbar, eindeutig
Epoch-Sekunden 1735689600 Heute 10 Stellen, kein Offset, keine Einheitenangabe
Epoch-Millisekunden 1735689600000 Heute 13 Stellen, die Vorgabe von JavaScript
ASP.NET AJAX "\/Date(1735689600000)\/" Millisekunden in eine Zeichenkette gewickelt, noch in älteren .NET-APIs

Die Stellenzahl ist der einzige Hinweis auf Feldebene für die letzten beiden. Eine aktuelle Epoche in Sekunden hat 10 Stellen und behält sie bis 2286; derselbe Zeitpunkt in Millisekunden hat 13. Wenn Sie einen Feed ohne Schema erben, zählen Sie die Stellen, bevor Sie raten, und denken Sie daran, dass ein Sekundenwert, der an eine Millisekunden-API geht, immer im frühen 1970 landet, weshalb genau dieser Bug so wiedererkennbar ist.

Der vierte ist eine echte Zeichenkette im JSON-Sinn. Die Backslashes sind ein legales Escape für /, nach dem Parsen halten Sie also den wörtlichen Text /Date(1735689600000)/ und müssen einen regulären Ausdruck darüberlaufen lassen. Manche Varianten führen einen Offset mit, etwa /Date(1735689600000-0800)/, wobei der Offset Zierrat ist: Die Zahl ist bereits UTC.

RFC 3339 ist nicht ganz ISO 8601

Man benutzt die Namen austauschbar, und dann akzeptiert die eine Seite, was die andere ablehnt. RFC 3339 ist ein Profil von ISO 8601: eine kleinere, striktere Grammatik, gewählt, damit Maschinen nicht uneins sein können.

ISO 8601 erlaubt Dinge, die RFC 3339 nicht erlaubt:

  • Das Basisformat ohne Trennzeichen, 20250101T000000Z
  • Wochendaten (2025-W01-3) und Ordinaldaten (2025-001)
  • Verringerte Genauigkeit, etwa 2025-01 oder nur 2025
  • Ein Komma als Dezimaltrenner bei den Sekunden, 00:00:00,5
  • Eine Ortszeit ganz ohne Offset

RFC 3339 verlangt immer ein vollständiges Datum, eine vollständige Zeit und einen Offset. Es erlaubt außerdem eines, das ISO 8601 verbietet: den Offset -00:00, der bedeutet, dass der Zeitpunkt bekannt ist, der lokale Offset aber nicht. Wenn Sie einen Parser oder Validator schreiben: -00:00 und +00:00 sind derselbe Zeitpunkt und verschiedene Aussagen.

Praktische Regel: Geben Sie RFC 3339 aus, mit großem T, großem Z und entweder ganzen Sekunden oder genau drei Nachkommastellen. Akzeptieren Sie notfalls etwas mehr, geben Sie es aber nie aus.

Z ist ein Offset, nicht das Fehlen eines Offsets

Z bedeutet, der Offset ist +00:00. Das ist eine Tatsache über den Zeitpunkt. Es ist keine Art zu sagen „keine Zeitzone“, und es ist keine Art zu sagen „die Zeitzone dieses Datensatzes ist UTC“. Das sind verschiedene Dinge, und dieser Unterschied ist es, der die Sache schwer macht.

"2025-01-01T00:00:00Z" und "2025-01-01T09:00:00+09:00" sind derselbe Zeitpunkt. Wenn Sie beim Eingang alles auf Z normalisieren, haben Sie den Zeitpunkt behalten und weggeworfen, wo die Nutzerin war. Für created_at ist das meist richtig und für einen Kalendertermin meist falsch, denn dort ist die lokale Wanduhrzeit das, was zählt, und der Offset ist womöglich erst am Tag selbst bekannt. Speichern Sie dafür die Ortszeit und den IANA-Zonennamen (Europe/Berlin, nicht +01:00) in getrennten Feldern; Offsets ändern sich zweimal im Jahr, und Regierungen ändern sie kurzfristig.

Geben Sie nie einen Zeitstempel ohne Offset aus. "2025-01-01T00:00:00" ist eine Zeichenkette, deren Bedeutung davon abhängt, welche Maschine sie liest, und JavaScript und Python lösen sie unterschiedlich auf.

Kalenderdaten sind keine Zeitstempel

Ein Geburtstag, ein Rechnungsfälligkeitsdatum und ein Feiertag sind keine Zeitpunkte. Sie haben keine Uhrzeit und keinen Offset, und einen anzuhängen ist ein Fehler, der sich für die Hälfte Ihrer Nutzer als Abweichung um einen Tag zeigt.

new Date('1990-07-14').toLocaleDateString('de-DE')
// '13.7.1990' überall westlich von UTC

Die ECMAScript-Spezifikation liest die reine Datumsform als UTC-Mitternacht, und der lokale Formatierer läuft dann rückwärts. Senden Sie "1990-07-14" als schlichte Zeichenkette, lassen Sie sie eine Zeichenkette und formatieren Sie sie, ohne überhaupt durch Date zu gehen. Wenn ein Wert nie „um ein paar Stunden falsch“ sein kann, sollte er keine Stunden mitführen.

JavaScript im Besonderen

Das Serialisieren funktioniert ohne Zutun, weil Date.prototype.toJSON toISOString aufruft:

JSON.stringify({ at: new Date(0) })
// '{"at":"1970-01-01T00:00:00.000Z"}'

JSON.stringify({ at: new Date(NaN) })
// '{"at":null}'   toJSON liefert null für ein nicht endliches Datum, es wirft nicht

Das Parsen funktioniert gar nicht. JSON.parse hat keine Ahnung, dass eine Zeichenkette ein Datum ist, ein Hin- und Rückweg gibt Ihnen also eine Zeichenkette zurück, und der Fehler taucht später auf, wenn irgendwas .getTime() darauf aufruft. Der übliche Flicken ist ein Reviver:

const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;

JSON.parse(text, (key, value) =>
  typeof value === 'string' && RFC3339.test(value) ? new Date(value) : value
);

Zwei Warnungen zu diesem Muster. Es ist eine Heuristik: Jede Zeichenkette, die wie ein Zeitstempel aussieht, wird einer, auch das Freitextfeld einer Nutzerin. Und Date lässt den Offset einfallen, +09:00 kommt also als UTC-Zeitpunkt zurück und der ursprüngliche Offset ist fort. Beleben Sie lieber nach Schlüsselpfad wieder, oder gar nicht und wandeln Sie an der Verwendungsstelle ausdrücklich um.

Temporal, der Nachfolger von Date, hat Typen, die diese Unterscheidungen sauber abbilden (Instant, PlainDate, ZonedDateTime), und ein PlainDate ist genau der Kalenderdatumstyp, nach dem dieser Artikel dauernd verlangt. Zum Zeitpunkt des Schreibens beginnt die Auslieferung in Browsern; prüfen Sie die aktuelle Unterstützung, bevor Sie sich darauf verlassen, und prüfen Sie, ob die Bundle-Größe Ihres Polyfills akzeptabel ist.

Python im Besonderen

datetime ist nicht JSON-serialisierbar, und der Notbehelf, zu dem man zuerst greift, ist auf subtile Weise falsch:

import json
from datetime import datetime, timezone

now = datetime.now(timezone.utc)

json.dumps({"at": now})
# TypeError: Object of type datetime is not JSON serializable

json.dumps({"at": now}, default=str)
# '{"at": "2025-01-01 00:00:00+00:00"}'   Leerzeichen als Trenner, kein RFC 3339

json.dumps({"at": now}, default=lambda o: o.isoformat())
# '{"at": "2025-01-01T00:00:00+00:00"}'   korrekt

default=str gibt Ihnen str(datetime), das ein Leerzeichen statt T verwendet. Es ist lesbar und es ist kein RFC 3339, ein strikter Konsument wird es also ablehnen.

Beim Zurücklesen kommt datetime.fromisoformat ab Python 3.11 mit dem Suffix Z zurecht. In 3.10 und früher wirft es ValueError: Invalid isoformat string, weshalb so viel älterer Code ein .replace("Z", "+00:00") vor dem Aufruf mitschleppt. Beachten Sie außerdem, dass isoformat() +00:00 statt Z ausgibt; wenn Ihr Konsument auf Z besteht, nehmen Sie diese Ersetzung auf dem Weg hinaus vor.

Das Schema rettet Sie standardmäßig nicht

Der naheliegende Schritt ist, die Form zu deklarieren:

{
  "type": "object",
  "properties": {
    "created_at": { "type": "string", "format": "date-time" },
    "due_on":     { "type": "string", "format": "date" }
  },
  "required": ["created_at"]
}

In JSON Schema 2019-09 und 2020-12 ist format standardmäßig eine Annotation, keine Zusicherung. Ohne Zutun akzeptieren die meisten Validatoren bereitwillig "created_at": "gestern" gegen dieses Schema, denn es ist eine Zeichenkette, und das Schlüsselwort format beschreibt nur eine Absicht. Sie müssen die Zusicherung ausdrücklich einschalten (in Ajv heißt das, ajv-formats zu ergänzen). Wie sich die Vokabulare für Annotation und Zusicherung trennen, steht in JSON Schema erklärt; einen ersten Entwurf aus einem echten Payload erzeugt der Schema-Generator.

Was senden, was akzeptieren

Senden Sie RFC 3339 mit ausdrücklichem Offset für Zeitpunkte, auf Z normalisiert, sofern der lokale Offset für die Leserin nicht bedeutsam ist. Senden Sie schlichte YYYY-MM-DD-Zeichenketten für Kalenderdaten. Benennen Sie Felder so, dass der Typ offensichtlich ist: created_at für einen Zeitpunkt, due_on für ein Datum, und wenn Sie wirklich eine Epoche verschicken müssen, nennen Sie das Feld expires_at_ms, damit die Einheit mitreist.

Akzeptieren Sie RFC 3339 mit oder ohne Sekundenbruchteile, Offsets in der Form +HH:MM oder Z, und weisen Sie alles ohne Offset zurück, statt zu raten. Validieren Sie die Zeichenkette, bevor Sie irgendetwas daraus bauen, denn new Date("Unsinn") liefert ein Invalid Date, das sich still fortpflanzt.

Fügen Sie ein echtes Payload in den Validator ein, um zu bestätigen, dass die Struktur trägt, und lesen Sie dann die Datumsfelder mit eigenen Augen: Zählen Sie bei jeder Zahl die Stellen und prüfen Sie, dass jede Zeitstempel-Zeichenkette auf einen Offset endet. Diese beiden Prüfungen fangen das meiste ab, was dieser Artikel beschreibt. Der Rest steht in API-Antworten entwerfen, wo die Entscheidung über Feldnamen einmal fällt und nie wieder aufgemacht wird.