JSON no tiene tipo fecha, así que elige uno y déjalo por escrito
JSON tiene seis tipos y ninguno es una fecha, así que cada base de código se inventa uno. De las cuatro respuestas habituales solo una es segura.
Cada afirmación de esta página está medida o tiene fuente. Cuando no es ninguna de las dos, lo dice.
Alguien avisa de que todos los registros de la tabla de administración se crearon el 21 de enero de 1970. El payload tiene buena pinta:
{ "created_at": 1735689600 }
Eso es el 1 de enero de 2025 en segundos desde la época. El front hizo new Date(1735689600), que toma milisegundos, así que leyó 1.735.689 segundos después de la época y aterrizó tres semanas dentro de 1970. No se lanzó nada. El número era válido, el tipo era correcto, y el significado se perdió entre dos servicios porque JSON no tiene forma de transportarlo.
La RFC 8259 te da objetos, arrays, cadenas, números, booleanos y null. No hay fecha. Lo que envíes es una cadena o un número que ambas partes han acordado en privado interpretar, y ese acuerdo vive en la documentación, o en la cabeza de nadie.
Las cuatro convenciones que te vas a encontrar
| Convención | Ejemplo | Qué es |
|---|---|---|
| Cadena RFC 3339 | "2025-01-01T00:00:00Z" |
Autodescriptiva, ordenable, sin ambigüedad |
| Segundos epoch | 1735689600 |
Hoy 10 dígitos, sin desplazamiento, sin etiqueta de unidades |
| Milisegundos epoch | 1735689600000 |
Hoy 13 dígitos, el valor por defecto de JavaScript |
| ASP.NET AJAX | "\/Date(1735689600000)\/" |
Milisegundos envueltos en una cadena, todavía en APIs .NET antiguas |
La cuenta de dígitos es la única pista a nivel de campo sobre las dos últimas. Una época actual en segundos tiene 10 dígitos y seguirá teniéndolos hasta 2286; el mismo instante en milisegundos tiene 13. Si heredas un feed sin esquema, cuenta los dígitos antes de adivinar, y recuerda que un valor en segundos pasado a una API de milisegundos siempre aterriza a principios de 1970, que es por lo que ese bug concreto se reconoce tan fácilmente.
La cuarta es una cadena de verdad en el sentido de JSON. Las barras invertidas son un escape legal de /, así que tras parsear sostienes el texto literal /Date(1735689600000)/ y tienes que pasarle una expresión regular. Algunas variantes llevan un desplazamiento, como /Date(1735689600000-0800)/, donde el desplazamiento es decoración: el número ya está en UTC.
RFC 3339 no es exactamente ISO 8601
La gente usa los nombres indistintamente, y luego un lado acepta algo que el otro rechaza. La RFC 3339 es un perfil de la ISO 8601: una gramática más pequeña y estricta, elegida para que las máquinas no puedan discrepar.
La ISO 8601 permite cosas que la RFC 3339 no:
- Formato básico sin separadores,
20250101T000000Z - Fechas por semana (
2025-W01-3) y fechas ordinales (2025-001) - Precisión reducida, como
2025-01o solo2025 - Una coma como separador decimal en los segundos,
00:00:00,5 - Una hora local sin desplazamiento alguno
La RFC 3339 exige una fecha completa, una hora completa y un desplazamiento, siempre. También admite algo que la ISO 8601 prohíbe: el desplazamiento -00:00, que significa que el instante se conoce pero el desplazamiento local no. Si escribes un parser o un validador, -00:00 y +00:00 son el mismo instante y afirmaciones distintas.
Regla práctica: emite RFC 3339, con T mayúscula, Z mayúscula, y o bien segundos enteros o exactamente tres dígitos fraccionarios. Acepta un poco más si no queda otra, pero nunca lo emitas.
Z es un desplazamiento, no la ausencia de uno
Z significa que el desplazamiento es +00:00. Es un hecho sobre el instante. No es una forma de decir «sin zona horaria», ni una forma de decir «la zona horaria de este registro es UTC». Son cosas distintas, y esa diferencia es lo que hace difícil el asunto.
"2025-01-01T00:00:00Z" y "2025-01-01T09:00:00+09:00" son el mismo instante. Si normalizas todo a Z al entrar, has conservado el instante y has tirado dónde estaba la persona. Eso suele ser correcto para created_at y suele ser incorrecto para una cita de calendario, donde el reloj de pared local es lo que le importa al usuario y el desplazamiento puede ni siquiera conocerse hasta que llegue el día. Para esos casos, guarda la hora local y el nombre de zona IANA (Europe/Berlin, no +01:00) en campos separados; los desplazamientos cambian dos veces al año y los gobiernos los cambian con poca antelación.
Nunca emitas una marca de tiempo sin desplazamiento. "2025-01-01T00:00:00" es una cadena cuyo significado depende de qué máquina la lea, y JavaScript y Python la resuelven de forma distinta.
Las fechas de calendario no son marcas de tiempo
Un cumpleaños, la fecha de vencimiento de una factura y un festivo no son instantes. No tienen hora ni desplazamiento, y adjuntarles uno es un bug que aparece como un desfase de un día para la mitad de tus usuarios.
new Date('1990-07-14').toLocaleDateString('es-ES')
// '13/7/1990' en cualquier lugar al oeste de UTC
La especificación de ECMAScript parsea la forma de solo fecha como medianoche UTC, y luego el formateador local la retrocede. Envía "1990-07-14" como cadena simple, mantenla como cadena y formatéala sin pasar por Date en absoluto. Si un valor no puede estar nunca «equivocado por unas horas», no debería llevar horas.
Particularidades de JavaScript
Serializar funciona de fábrica, porque Date.prototype.toJSON llama a toISOString:
JSON.stringify({ at: new Date(0) })
// '{"at":"1970-01-01T00:00:00.000Z"}'
JSON.stringify({ at: new Date(NaN) })
// '{"at":null}' toJSON devuelve null para una fecha no finita, no lanza error
Parsear no funciona en absoluto. JSON.parse no tiene ni idea de que una cadena es una fecha, así que un viaje de ida y vuelta te devuelve una cadena, y el bug aparece más tarde cuando algo llama a .getTime() sobre ella. El parche habitual es 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
);
Dos avisos sobre ese patrón. Es una heurística: cualquier cadena que parezca una marca de tiempo se convierte en una, incluido el campo de texto libre de un usuario. Y Date colapsa el desplazamiento, así que +09:00 vuelve como un instante UTC y el desplazamiento original desaparece. Es mejor revivir por ruta de clave, o no revivir en absoluto y convertir explícitamente en el punto de uso.
Temporal, el reemplazo de Date, tiene tipos que modelan estas distinciones como es debido (Instant, PlainDate, ZonedDateTime) y un PlainDate es exactamente el tipo de fecha de calendario que este artículo lleva pidiendo. Al escribir esto ha empezado a llegar a los navegadores; comprueba el soporte actual antes de depender de él, y comprueba si el tamaño del bundle de tu polyfill es aceptable.
Particularidades de Python
datetime no es serializable a JSON, y el apaño al que la gente recurre primero está sutilmente mal:
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 espacio, no es RFC 3339
json.dumps({"at": now}, default=lambda o: o.isoformat())
# '{"at": "2025-01-01T00:00:00+00:00"}' correcto
default=str te da str(datetime), que usa un espacio en vez de T. Es legible y no es RFC 3339, así que un consumidor estricto lo rechazará.
Al leer de vuelta, datetime.fromisoformat maneja el sufijo Z desde Python 3.11. En 3.10 y anteriores lanza ValueError: Invalid isoformat string, que es por lo que tanto código antiguo lleva un .replace("Z", "+00:00") antes de la llamada. Ten en cuenta además que isoformat() emite +00:00 en vez de Z; si tu consumidor insiste en Z, haz esa sustitución a la salida.
El esquema no te salvará por defecto
El movimiento obvio es declarar la forma:
{
"type": "object",
"properties": {
"created_at": { "type": "string", "format": "date-time" },
"due_on": { "type": "string", "format": "date" }
},
"required": ["created_at"]
}
En JSON Schema 2019-09 y 2020-12, format es por defecto una anotación, no una aserción. De serie, la mayoría de validadores aceptarán tan contentos "created_at": "ayer" contra ese esquema, porque es una cadena y la palabra clave format solo describe una intención. Tienes que activar la aserción explícitamente (en Ajv eso significa añadir ajv-formats). Mira JSON Schema explicado para ver cómo se separan los vocabularios de anotación y aserción, y genera un primer borrador a partir de un payload real con el generador de esquemas.
Qué enviar, qué aceptar
Envía RFC 3339 con desplazamiento explícito para los instantes, normalizado a Z salvo que el desplazamiento local tenga significado para quien lee. Envía cadenas YYYY-MM-DD simples para fechas de calendario. Nombra los campos de forma que el tipo sea evidente: created_at para un instante, due_on para una fecha, y si de verdad tienes que enviar una época, llama al campo expires_at_ms para que las unidades viajen con él.
Acepta RFC 3339 con o sin segundos fraccionarios, desplazamientos en forma +HH:MM o Z, y rechaza cualquier cosa sin desplazamiento en vez de adivinar. Valida la cadena antes de construir nada a partir de ella, porque new Date("tonterías") te da un Invalid Date que se propaga en silencio.
Pega un payload real en el validador para confirmar que la estructura es sólida, y luego lee los campos de fecha con tus propios ojos: cuenta los dígitos de cada número y comprueba que toda cadena de marca de tiempo termina en un desplazamiento. Esas dos comprobaciones cazan casi todo lo que describe este artículo. El resto está cubierto en Diseño de respuestas de API, donde la decisión de nombrado de campos se toma una vez y no se vuelve a revisar.