JSON n’a pas de type date : choisissez-en un et écrivez-le
JSON a six types et aucun n’est une date, alors chaque base de code en invente une. Des quatre réponses courantes, une seule est sûre.
Chaque affirmation de cette page est soit mesurée, soit sourcée. Quand elle n’est ni l’une ni l’autre, la page le dit.
Quelqu’un signale que tous les enregistrements de la table d’administration ont été créés le 21 janvier 1970. Le payload a l’air correct :
{ "created_at": 1735689600 }
C’est le 1er janvier 2025 en secondes depuis l’epoch. Le front a fait new Date(1735689600), qui attend des millisecondes, il a donc lu 1 735 689 secondes après l’epoch et a atterri trois semaines après le début de 1970. Rien n’a été levé. Le nombre était valide, le type était correct, et le sens s’est perdu entre deux services parce que JSON n’a aucun moyen de le transporter.
La RFC 8259 vous donne des objets, des tableaux, des chaînes, des nombres, des booléens et null. Il n’y a pas de date. Ce que vous envoyez est une chaîne ou un nombre que les deux côtés se sont entendus en privé à interpréter, et cet accord vit dans la documentation, ou dans la tête de personne.
Les quatre conventions que vous rencontrerez
| Convention | Exemple | Ce que c’est |
|---|---|---|
| Chaîne RFC 3339 | "2025-01-01T00:00:00Z" |
Auto-descriptive, triable, sans ambiguïté |
| Secondes epoch | 1735689600 |
10 chiffres aujourd’hui, pas de décalage, pas d’étiquette d’unité |
| Millisecondes epoch | 1735689600000 |
13 chiffres aujourd’hui, la valeur par défaut de JavaScript |
| ASP.NET AJAX | "\/Date(1735689600000)\/" |
Des millisecondes enveloppées dans une chaîne, encore présentes dans d’anciennes API .NET |
Le nombre de chiffres est le seul indice, au niveau du champ, pour distinguer les deux dernières. Un epoch actuel en secondes fait 10 chiffres et le restera jusqu’en 2286 ; le même instant en millisecondes en fait 13. Si vous héritez d’un flux sans schéma, comptez les chiffres avant de deviner, et rappelez-vous qu’une valeur en secondes fournie à une API en millisecondes atterrit toujours au début de 1970, ce qui rend ce bug si reconnaissable.
La quatrième est une véritable chaîne au sens JSON. Les barres obliques inverses sont un échappement légal de /, donc après analyse vous tenez le texte littéral /Date(1735689600000)/ et il faut y passer une expression régulière. Certaines variantes portent un décalage, comme /Date(1735689600000-0800)/, où le décalage est décoratif : le nombre est déjà en UTC.
RFC 3339 n’est pas tout à fait ISO 8601
Les gens emploient les deux noms indifféremment, puis un côté accepte ce que l’autre refuse. La RFC 3339 est un profil de l’ISO 8601 : une grammaire plus petite et plus stricte, choisie pour que les machines ne puissent pas diverger.
L’ISO 8601 autorise des choses que la RFC 3339 refuse :
- Le format de base sans séparateurs,
20250101T000000Z - Les dates de semaine (
2025-W01-3) et les dates ordinales (2025-001) - Une précision réduite, comme
2025-01ou simplement2025 - Une virgule comme séparateur décimal sur les secondes,
00:00:00,5 - Une heure locale sans aucun décalage
La RFC 3339 exige toujours une date complète, une heure complète et un décalage. Elle autorise aussi une chose que l’ISO 8601 interdit : le décalage -00:00, qui signifie que l’instant est connu mais pas le décalage local. Si vous écrivez un analyseur ou un validateur, -00:00 et +00:00 sont le même instant et deux affirmations différentes.
Règle pratique : émettez du RFC 3339, avec T en majuscule, Z en majuscule, et soit des secondes entières, soit exactement trois décimales. Acceptez un peu plus s’il le faut, mais n’en émettez jamais.
Z est un décalage, pas l’absence de décalage
Z signifie que le décalage est +00:00. C’est un fait sur l’instant. Ce n’est pas une façon de dire « pas de fuseau horaire », ni une façon de dire « le fuseau de cet enregistrement est UTC ». Ce sont des choses différentes, et c’est cette différence qui rend le sujet difficile.
"2025-01-01T00:00:00Z" et "2025-01-01T09:00:00+09:00" sont le même instant. Si vous normalisez tout en Z à l’entrée, vous avez gardé l’instant et jeté l’endroit où se trouvait l’utilisateur. C’est en général correct pour created_at et en général faux pour un rendez-vous d’agenda, où l’heure murale locale est ce qui intéresse l’utilisateur et où le décalage peut même n’être connu qu’au jour dit. Pour ceux-là, stockez l’heure locale et le nom de zone IANA (Europe/Berlin, pas +01:00) dans des champs séparés ; les décalages changent deux fois par an et les gouvernements les modifient avec peu de préavis.
N’émettez jamais un horodatage sans décalage. "2025-01-01T00:00:00" est une chaîne dont le sens dépend de la machine qui la lit, et JavaScript et Python la résolvent différemment.
Les dates calendaires ne sont pas des horodatages
Un anniversaire, une échéance de facture et un jour férié ne sont pas des instants. Ils n’ont ni heure ni décalage, et leur en attacher un est un bug qui se manifeste par un décalage d’un jour pour la moitié de vos utilisateurs.
new Date('1990-07-14').toLocaleDateString('fr-FR')
// '13/07/1990' partout à l’ouest d’UTC
La spécification ECMAScript analyse la forme date seule comme minuit UTC, puis le formateur local la fait reculer. Envoyez "1990-07-14" comme simple chaîne, gardez-la en chaîne, et formatez-la sans passer par Date du tout. Si une valeur ne peut jamais être « fausse de quelques heures », elle ne devrait pas porter d’heures.
Spécificités JavaScript
La sérialisation marche d’emblée, parce que Date.prototype.toJSON appelle toISOString :
JSON.stringify({ at: new Date(0) })
// '{"at":"1970-01-01T00:00:00.000Z"}'
JSON.stringify({ at: new Date(NaN) })
// '{"at":null}' toJSON renvoie null pour une date non finie, il ne lève rien
L’analyse ne marche pas du tout. JSON.parse n’a aucune idée qu’une chaîne est une date, donc un aller-retour vous rend une chaîne, et le bug apparaît plus tard quand quelque chose appelle .getTime() dessus. Le rustine habituelle est un 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
);
Deux avertissements sur ce motif. C’est une heuristique : toute chaîne qui ressemble à un horodatage en devient un, y compris le champ de texte libre d’un utilisateur. Et Date écrase le décalage, donc +09:00 revient comme un instant UTC et le décalage d’origine a disparu. Préférez raviver par chemin de clé, ou ne rien raviver du tout et convertir explicitement au point d’usage.
Temporal, le remplaçant de Date, propose des types qui modélisent correctement ces distinctions (Instant, PlainDate, ZonedDateTime) et un PlainDate est exactement le type de date calendaire que cet article ne cesse de réclamer. Il commence à arriver dans les navigateurs au moment où ces lignes sont écrites ; vérifiez le support actuel avant d’en dépendre, et vérifiez si la taille du bundle de votre polyfill est acceptable.
Spécificités Python
datetime n’est pas sérialisable en JSON, et le contournement auquel on pense d’abord est subtilement faux :
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"}' séparateur espace, pas du RFC 3339
json.dumps({"at": now}, default=lambda o: o.isoformat())
# '{"at": "2025-01-01T00:00:00+00:00"}' correct
default=str vous donne str(datetime), qui met une espace au lieu de T. C’est lisible et ce n’est pas du RFC 3339, donc un consommateur strict le refusera.
À la lecture, datetime.fromisoformat gère le suffixe Z à partir de Python 3.11. Sur 3.10 et antérieur, il lève ValueError: Invalid isoformat string, d’où le .replace("Z", "+00:00") que traîne tant de code ancien avant l’appel. Notez aussi qu’isoformat() émet +00:00 plutôt que Z ; si votre consommateur exige Z, faites la substitution à la sortie.
Le schéma ne vous sauvera pas par défaut
Le geste évident est de déclarer la forme :
{
"type": "object",
"properties": {
"created_at": { "type": "string", "format": "date-time" },
"due_on": { "type": "string", "format": "date" }
},
"required": ["created_at"]
}
Dans JSON Schema 2019-09 et 2020-12, format est par défaut une annotation, pas une assertion. Tel quel, la plupart des validateurs accepteront volontiers "created_at": "hier" contre ce schéma, parce que c’est une chaîne et que le mot-clé format ne décrit qu’une intention. Il faut activer l’assertion explicitement (dans Ajv, cela veut dire ajouter ajv-formats). Voyez JSON Schema expliqué pour la séparation entre vocabulaires d’annotation et d’assertion, et générez un premier brouillon à partir d’un vrai payload avec le générateur de schéma.
Quoi envoyer, quoi accepter
Envoyez du RFC 3339 avec décalage explicite pour les instants, normalisé en Z sauf si le décalage local a du sens pour le lecteur. Envoyez de simples chaînes YYYY-MM-DD pour les dates calendaires. Nommez les champs pour que le type soit évident : created_at pour un instant, due_on pour une date, et si vous devez vraiment expédier un epoch, appelez le champ expires_at_ms pour que l’unité voyage avec lui.
Acceptez du RFC 3339 avec ou sans fraction de seconde, des décalages sous la forme +HH:MM ou Z, et refusez tout ce qui n’a pas de décalage plutôt que de deviner. Validez la chaîne avant d’en construire quoi que ce soit, parce que new Date("n’importe quoi") vous donne une Invalid Date qui se propage en silence.
Collez un payload réel dans le validateur pour confirmer que la structure tient, puis lisez les champs de date de vos propres yeux : comptez les chiffres de chaque nombre et vérifiez que chaque chaîne d’horodatage se termine par un décalage. Ces deux vérifications attrapent l’essentiel de ce que décrit cet article. Le reste est couvert dans Conception des réponses d’API, où la décision de nommage des champs se prend une fois et ne se rediscute jamais.