JSON non ha un tipo data: sceglietene uno e mettetelo per iscritto
JSON ha sei tipi e nessuno di essi è una data, quindi ogni codebase se ne inventa una. Delle quattro risposte comuni, una sola è sicura.
Ogni affermazione di questa pagina è misurata oppure ha una fonte. Quando non è né l’una né l’altra, la pagina lo dice.
Qualcuno segnala che ogni record nella tabella di amministrazione risulta creato il 21 gennaio 1970. Il payload sembra a posto:
{ "created_at": 1735689600 }
È il 1º gennaio 2025 in secondi dall’epoch. Il front end ha fatto new Date(1735689600), che vuole millisecondi, quindi ha letto 1.735.689 secondi dopo l’epoch ed è atterrato tre settimane dentro il 1970. Non è stato sollevato nulla. Il numero era valido, il tipo era corretto, e il significato si è perso tra due servizi perché JSON non ha modo di trasportarlo.
La RFC 8259 ti dà oggetti, array, stringhe, numeri, booleani e null. Non c’è la data. Quello che mandi è una stringa o un numero che le due parti si sono accordate in privato a interpretare, e quell’accordo vive nella documentazione, oppure nella testa di nessuno.
Le quattro convenzioni che incontrerai
| Convenzione | Esempio | Che cos’è |
|---|---|---|
| Stringa RFC 3339 | "2025-01-01T00:00:00Z" |
Autodescrittiva, ordinabile, non ambigua |
| Secondi epoch | 1735689600 |
Oggi 10 cifre, nessuno scostamento, nessuna etichetta di unità |
| Millisecondi epoch | 1735689600000 |
Oggi 13 cifre, l’impostazione predefinita di JavaScript |
| ASP.NET AJAX | "\/Date(1735689600000)\/" |
Millisecondi avvolti in una stringa, ancora presenti in vecchie API .NET |
Il numero di cifre è l’unico indizio, a livello di campo, sulle ultime due. Un epoch attuale in secondi è di 10 cifre e resta a 10 fino al 2286; lo stesso istante in millisecondi ne ha 13. Se erediti un feed senza schema, conta le cifre prima di indovinare, e ricorda che un valore in secondi passato a un’API in millisecondi finisce sempre all’inizio del 1970: ecco perché quel bug specifico è così riconoscibile.
La quarta è una vera stringa in senso JSON. Le barre rovesciate sono un escape lecito di /, quindi dopo l’analisi hai in mano il testo letterale /Date(1735689600000)/ e devi passarci sopra un’espressione regolare. Alcune varianti portano uno scostamento, come /Date(1735689600000-0800)/, dove lo scostamento è decorativo: il numero è già UTC.
RFC 3339 non è esattamente ISO 8601
I due nomi si usano come sinonimi, e poi una parte accetta qualcosa che l’altra rifiuta. La RFC 3339 è un profilo della ISO 8601: una grammatica più piccola e più severa, scelta perché le macchine non possano essere in disaccordo.
La ISO 8601 permette cose che la RFC 3339 non permette:
- Il formato base senza separatori,
20250101T000000Z - Date per settimana (
2025-W01-3) e date ordinali (2025-001) - Precisione ridotta, come
2025-01o solo2025 - Una virgola come separatore decimale sui secondi,
00:00:00,5 - Un’ora locale del tutto priva di scostamento
La RFC 3339 richiede sempre una data completa, un’ora completa e uno scostamento. Ammette inoltre una cosa che la ISO 8601 vieta: lo scostamento -00:00, che significa che l’istante è noto ma lo scostamento locale no. Se scrivi un parser o un validatore, -00:00 e +00:00 sono lo stesso istante e due affermazioni diverse.
Regola pratica: emetti RFC 3339, con T maiuscola, Z maiuscola, e o secondi interi o esattamente tre cifre decimali. Accettane un po’ di più se proprio devi, ma non emetterlo mai.
Z è uno scostamento, non l’assenza di scostamento
Z significa che lo scostamento è +00:00. È un fatto sull’istante. Non è un modo di dire «nessun fuso orario», e non è un modo di dire «il fuso di questo record è UTC». Sono cose diverse, ed è questa differenza a rendere la faccenda difficile.
"2025-01-01T00:00:00Z" e "2025-01-01T09:00:00+09:00" sono lo stesso istante. Se normalizzi tutto a Z in ingresso, hai conservato l’istante e buttato via dove si trovava l’utente. Di solito è corretto per created_at e di solito è sbagliato per un appuntamento di calendario, dove l’orologio da parete locale è ciò che interessa e lo scostamento potrebbe non essere nemmeno noto fino al giorno stesso. Per quelli, memorizza l’ora locale e il nome di zona IANA (Europe/Berlin, non +01:00) in campi separati; gli scostamenti cambiano due volte l’anno e i governi li modificano con poco preavviso.
Non emettere mai un timestamp senza scostamento. "2025-01-01T00:00:00" è una stringa il cui significato dipende da quale macchina la legge, e JavaScript e Python la risolvono in modo diverso.
Le date di calendario non sono timestamp
Un compleanno, la scadenza di una fattura e una festività non sono istanti. Non hanno ora né scostamento, e attaccargliene uno è un bug che si manifesta come uno scarto di un giorno per metà dei tuoi utenti.
new Date('1990-07-14').toLocaleDateString('it-IT')
// '13/7/1990' ovunque a ovest di UTC
La specifica ECMAScript analizza la forma di sola data come mezzanotte UTC, poi il formattatore locale la riporta indietro. Manda "1990-07-14" come semplice stringa, tienila stringa e formattala senza passare affatto da Date. Se un valore non può mai essere «sbagliato di qualche ora», non dovrebbe portarsi dietro delle ore.
Specifiche di JavaScript
Serializzare funziona da subito, perché Date.prototype.toJSON chiama toISOString:
JSON.stringify({ at: new Date(0) })
// '{"at":"1970-01-01T00:00:00.000Z"}'
JSON.stringify({ at: new Date(NaN) })
// '{"at":null}' toJSON restituisce null per una data non finita, non solleva
Analizzare non funziona per niente. JSON.parse non ha idea che una stringa sia una data, quindi un’andata e ritorno ti restituisce una stringa, e il bug compare più tardi quando qualcosa ci chiama sopra .getTime(). La toppa consueta è 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
);
Due avvertenze su quel modello. È un’euristica: qualsiasi stringa che somigli a un timestamp lo diventa, compreso il campo di testo libero di un utente. E Date fa collassare lo scostamento, quindi +09:00 torna come istante UTC e lo scostamento originale è perduto. Meglio rivivificare per percorso di chiave, oppure non rivivificare affatto e convertire esplicitamente nel punto d’uso.
Temporal, il sostituto di Date, ha tipi che modellano queste distinzioni come si deve (Instant, PlainDate, ZonedDateTime) e un PlainDate è esattamente il tipo di data di calendario che questo articolo continua a chiedere. Al momento in cui scriviamo ha iniziato ad arrivare nei browser; verifica il supporto attuale prima di dipenderne, e verifica se la dimensione del bundle del tuo polyfill è accettabile.
Specifiche di Python
datetime non è serializzabile in JSON, e la scappatoia a cui si ricorre per prima è sottilmente sbagliata:
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"}' separatore spazio, non è RFC 3339
json.dumps({"at": now}, default=lambda o: o.isoformat())
# '{"at": "2025-01-01T00:00:00+00:00"}' corretto
default=str ti dà str(datetime), che usa uno spazio al posto della T. È leggibile e non è RFC 3339, quindi un consumatore severo lo rifiuterà.
Rileggendo, datetime.fromisoformat gestisce il suffisso Z da Python 3.11 in poi. Su 3.10 e precedenti solleva ValueError: Invalid isoformat string, ed è per questo che tanto codice più vecchio si porta dietro un .replace("Z", "+00:00") prima della chiamata. Nota anche che isoformat() emette +00:00 anziché Z; se il tuo consumatore pretende Z, fai quella sostituzione in uscita.
Lo schema non ti salva, di suo
La mossa ovvia è dichiarare la forma:
{
"type": "object",
"properties": {
"created_at": { "type": "string", "format": "date-time" },
"due_on": { "type": "string", "format": "date" }
},
"required": ["created_at"]
}
In JSON Schema 2019-09 e 2020-12, format è per impostazione predefinita un’annotazione, non un’asserzione. Così com’è, la maggior parte dei validatori accetterà tranquillamente "created_at": "ieri" contro quello schema, perché è una stringa e la parola chiave format descrive solo un’intenzione. Devi attivare l’asserzione esplicitamente (in Ajv significa aggiungere ajv-formats). Vedi JSON Schema spiegato per come si separano i vocabolari di annotazione e asserzione, e genera una prima bozza da un payload reale con il generatore di schemi.
Che cosa mandare, che cosa accettare
Manda RFC 3339 con scostamento esplicito per gli istanti, normalizzato a Z a meno che lo scostamento locale non significhi qualcosa per chi legge. Manda semplici stringhe YYYY-MM-DD per le date di calendario. Nomina i campi in modo che il tipo sia ovvio: created_at per un istante, due_on per una data, e se proprio devi spedire un epoch, chiama il campo expires_at_ms così l’unità viaggia con lui.
Accetta RFC 3339 con o senza frazioni di secondo, scostamenti nella forma +HH:MM o Z, e rifiuta qualsiasi cosa priva di scostamento anziché indovinare. Valida la stringa prima di costruirci qualcosa, perché new Date("sciocchezze") ti dà un Invalid Date che si propaga in silenzio.
Incolla un payload reale nel validatore per confermare che la struttura regga, poi leggi i campi data con i tuoi occhi: conta le cifre su ogni numero e controlla che ogni stringa di timestamp finisca con uno scostamento. Questi due controlli intercettano quasi tutto ciò che questo articolo descrive. Il resto è coperto in Progettare le risposte di API, dove la decisione sui nomi dei campi si prende una volta e non si riapre più.