JSON não tem tipo data, então escolha um e deixe registrado
JSON tem seis tipos e nenhum deles é uma data, então cada base de código inventa a sua. Das quatro respostas comuns, só uma é segura.
Cada afirmação desta página foi medida ou tem fonte. Quando não é nem uma coisa nem outra, a página diz isso.
Alguém relata que todo registro da tabela de administração foi criado em 21 de janeiro de 1970. O payload parece bom:
{ "created_at": 1735689600 }
Isso é 1º de janeiro de 2025 em segundos desde a epoch. O front fez new Date(1735689600), que recebe milissegundos, então leu 1.735.689 segundos depois da epoch e aterrissou três semanas dentro de 1970. Nada foi lançado. O número era válido, o tipo estava certo, e o significado se perdeu entre dois serviços porque o JSON não tem como carregá-lo.
A RFC 8259 te dá objetos, arrays, strings, números, booleanos e null. Não existe data. O que você envia é uma string ou um número que os dois lados combinaram em particular como interpretar, e essa combinação mora na documentação, ou na cabeça de ninguém.
As quatro convenções que você vai encontrar
| Convenção | Exemplo | O que é |
|---|---|---|
| String RFC 3339 | "2025-01-01T00:00:00Z" |
Autodescritiva, ordenável, sem ambiguidade |
| Segundos epoch | 1735689600 |
Hoje 10 dígitos, sem deslocamento, sem rótulo de unidade |
| Milissegundos epoch | 1735689600000 |
Hoje 13 dígitos, o padrão do JavaScript |
| ASP.NET AJAX | "\/Date(1735689600000)\/" |
Milissegundos embrulhados numa string, ainda em APIs .NET antigas |
A contagem de dígitos é a única pista no nível do campo sobre as duas últimas. Uma epoch atual em segundos tem 10 dígitos e continua com 10 até 2286; o mesmo instante em milissegundos tem 13. Se você herdar um feed sem schema, conte os dígitos antes de chutar, e lembre que um valor em segundos entregue a uma API de milissegundos sempre cai no começo de 1970, que é por isso que esse bug específico é tão reconhecível.
A quarta é uma string de verdade no sentido do JSON. As barras invertidas são um escape legal de /, então depois do parsing você tem o texto literal /Date(1735689600000)/ e precisa passar uma regex nele. Algumas variantes trazem um deslocamento, como /Date(1735689600000-0800)/, em que o deslocamento é decoração: o número já está em UTC.
RFC 3339 não é exatamente ISO 8601
As pessoas usam os nomes de forma intercambiável, e aí um lado aceita algo que o outro rejeita. A RFC 3339 é um perfil da ISO 8601: uma gramática menor e mais estrita, escolhida para que as máquinas não possam discordar.
A ISO 8601 permite coisas que a RFC 3339 não permite:
- Formato básico sem separadores,
20250101T000000Z - Datas por semana (
2025-W01-3) e datas ordinais (2025-001) - Precisão reduzida, como
2025-01ou só2025 - Vírgula como separador decimal nos segundos,
00:00:00,5 - Uma hora local sem deslocamento nenhum
A RFC 3339 exige uma data completa, uma hora completa e um deslocamento, sempre. Ela também permite uma coisa que a ISO 8601 proíbe: o deslocamento -00:00, significando que o instante é conhecido mas o deslocamento local não. Se você escreve um parser ou um validador, -00:00 e +00:00 são o mesmo instante e afirmações diferentes.
Regra prática: emita RFC 3339, com T maiúsculo, Z maiúsculo, e ou segundos inteiros ou exatamente três casas fracionárias. Aceite um pouco mais se for preciso, mas nunca emita.
Z é um deslocamento, não a ausência de um
Z significa que o deslocamento é +00:00. É um fato sobre o instante. Não é um jeito de dizer “sem fuso horário”, nem um jeito de dizer “o fuso deste registro é UTC”. São coisas diferentes, e essa diferença é o que torna isto difícil.
"2025-01-01T00:00:00Z" e "2025-01-01T09:00:00+09:00" são o mesmo instante. Se você normaliza tudo para Z na entrada, manteve o instante e jogou fora onde a pessoa estava. Isso costuma estar certo para created_at e costuma estar errado para um compromisso de calendário, em que o relógio de parede local é o que importa e o deslocamento pode nem ser conhecido até o dia chegar. Para esses, guarde a hora local e o nome de zona IANA (Europe/Berlin, não +01:00) em campos separados; deslocamentos mudam duas vezes por ano e governos os alteram em cima da hora.
Nunca emita um timestamp sem deslocamento. "2025-01-01T00:00:00" é uma string cujo significado depende de qual máquina lê, e JavaScript e Python resolvem diferente.
Datas de calendário não são timestamps
Um aniversário, o vencimento de uma fatura e um feriado não são instantes. Não têm hora nem deslocamento, e anexar um é um bug que aparece como erro de um dia para metade dos seus usuários.
new Date('1990-07-14').toLocaleDateString('pt-BR')
// '13/07/1990' em qualquer lugar a oeste do UTC
A especificação do ECMAScript faz o parsing da forma só-data como meia-noite UTC, e então o formatador local a puxa para trás. Envie "1990-07-14" como string simples, mantenha como string, e formate sem passar pelo Date de jeito nenhum. Se um valor nunca pode estar “errado por algumas horas”, ele não deveria estar carregando horas.
Detalhes do JavaScript
Serializar funciona de fábrica, porque Date.prototype.toJSON chama toISOString:
JSON.stringify({ at: new Date(0) })
// '{"at":"1970-01-01T00:00:00.000Z"}'
JSON.stringify({ at: new Date(NaN) })
// '{"at":null}' toJSON devolve null para uma data não finita, não lança
Fazer o parsing não funciona de jeito nenhum. O JSON.parse não faz ideia de que uma string é uma data, então a ida e volta te devolve uma string, e o bug aparece depois quando algo chama .getTime() nela. O remendo usual é um 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
);
Dois avisos sobre esse padrão. É uma heurística: qualquer string que pareça um timestamp vira um, inclusive o campo de texto livre de um usuário. E o Date colapsa o deslocamento, então +09:00 volta como instante UTC e o deslocamento original se foi. Prefira reviver por caminho de chave, ou não reviver nada e converter explicitamente no ponto de uso.
O Temporal, substituto do Date, tem tipos que modelam essas distinções direito (Instant, PlainDate, ZonedDateTime) e um PlainDate é exatamente o tipo de data de calendário que este artigo fica pedindo. Ele começou a chegar aos navegadores enquanto isto era escrito; verifique o suporte atual antes de depender dele, e verifique se o tamanho de bundle do seu polyfill é aceitável.
Detalhes do Python
datetime não é serializável para JSON, e a saída que as pessoas buscam primeiro está sutilmente errada:
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"}' separador de espaço, não é RFC 3339
json.dumps({"at": now}, default=lambda o: o.isoformat())
# '{"at": "2025-01-01T00:00:00+00:00"}' correto
default=str te dá str(datetime), que usa um espaço em vez de T. É legível e não é RFC 3339, então um consumidor estrito vai rejeitar.
Na leitura de volta, datetime.fromisoformat lida com o sufixo Z a partir do Python 3.11. No 3.10 e anteriores ele levanta ValueError: Invalid isoformat string, que é o motivo de tanto código antigo carregar um .replace("Z", "+00:00") antes da chamada. Note também que isoformat() emite +00:00 em vez de Z; se o seu consumidor insiste em Z, faça essa substituição na saída.
O schema não vai te salvar por padrão
O movimento óbvio é declarar a forma:
{
"type": "object",
"properties": {
"created_at": { "type": "string", "format": "date-time" },
"due_on": { "type": "string", "format": "date" }
},
"required": ["created_at"]
}
No JSON Schema 2019-09 e 2020-12, format é por padrão uma anotação, não uma asserção. De saída, a maioria dos validadores aceita alegremente "created_at": "ontem" contra esse schema, porque é uma string e a palavra-chave format só descreve intenção. Você precisa ligar a asserção explicitamente (no Ajv isso quer dizer adicionar ajv-formats). Veja JSON Schema explicado para como os vocabulários de anotação e asserção se separam, e gere um primeiro rascunho a partir de um payload real com o gerador de schema.
O que enviar, o que aceitar
Envie RFC 3339 com deslocamento explícito para instantes, normalizado em Z a menos que o deslocamento local signifique algo para quem lê. Envie strings YYYY-MM-DD simples para datas de calendário. Nomeie os campos de modo que o tipo seja óbvio: created_at para instante, due_on para data, e se você realmente tiver que enviar uma epoch, chame o campo de expires_at_ms para a unidade viajar junto.
Aceite RFC 3339 com ou sem frações de segundo, deslocamentos na forma +HH:MM ou Z, e rejeite qualquer coisa sem deslocamento em vez de chutar. Valide a string antes de construir qualquer coisa a partir dela, porque new Date("besteira") te dá um Invalid Date que se propaga em silêncio.
Cole um payload real no validador para confirmar que a estrutura está firme, depois leia os campos de data com os próprios olhos: conte os dígitos de cada número e confira que toda string de timestamp termina num deslocamento. Essas duas conferências pegam a maior parte do que este artigo descreve. O resto está coberto em Design de respostas de API, onde a decisão de nomear campos é tomada uma vez e nunca mais revisitada.