¿Guardo una fecha como timestamp o como cadena ISO?
Una revisión de código se atasca en una columna: alguien guardó un timestamp Unix entero, otra persona una cadena ISO, y la API de terceros devuelve texto que termina en Z. ¿Cuál es «lo correcto»? La discusión casi siempre encalla en entero contra cadena, que es la pregunta menos importante y la última que hay que hacer. Lo que decide es todo lo que viene antes.
Una revisión de código se atasca en una columna. Un desarrollador guardó created_at como BIGINT —un timestamp Unix entero—. Otro insistió en un timestamptz de PostgreSQL que el driver serializa como una cadena ISO. Mientras tanto, la API de terceros con la que te integras te entrega "2026-07-15T22:00:00Z", y el JWT que emites lleva exp como un entero de diez dígitos. ¿Cuál es «lo correcto»? La discusión casi siempre se traba en el mismo punto —entero o cadena— y ahí se queda.
Aquí está la cuestión: entero o cadena es la pregunta menos importante y la última de una secuencia. Hay una pregunta antes que, si la respondes mal, vuelve irrelevante la elección entre entero y cadena —ambos caminos se estrellan igual—. Esa pregunta es: ¿qué tipo de valor es este? ¿Un instante, una fecha de calendario, un compromiso local futuro o una duración? Respóndela primero, y luego pregunta cómo guardarlo. Porque una vez que has establecido que es un instante, un timestamp entero y una cadena ISO con offset son dos notaciones de la misma información —ninguna es más «correcta», y eliges por concesiones de ingeniería, no por corrección—. Lo que de verdad fabrica bugs es una tercera respuesta que nadie eligió a propósito pero que se cuela: una hora local sin zona.
Esta es la cuarta pieza y el cierre del grupo de fecha y hora. Las tres primeras cubrieron el número en sí (por qué un timestamp Unix está mal), el montón de nombres a su alrededor (la diferencia entre UTC, GMT e ISO 8601) y la frontera de la fecha de calendario (por qué una fecha se desfasa un día). Esta las aterriza en una pregunta muy práctica: cuando de verdad escribes una hora en una base de datos, o la sueltas en una respuesta de API, ¿qué escribes?
Pregunta «qué es» antes de «qué forma toma»
Entero o cadena es una pregunta de forma. La más importante, la que viene antes, es de tipo. Casi todos los bugs de hora en la capa de almacenamiento eligen el tipo equivocado en este paso, y ninguna astucia de entero-contra-cadena lo recupera después. Hay cuatro tipos de valor con forma de fecha, y cada uno quiere algo distinto:
- Un instante —
created_at, la hora de un log, el momento en que se aprobó un pago. Un punto absoluto en la línea temporal, el mismo para todos. Guárdalo como un instante con un ancla explícita: un timestamp Unix, o una cadena ISO con offset. Solo para este tipo esas dos son la misma información en dos notaciones. - Una fecha de calendario — un cumpleaños, la fecha de una factura, un vencimiento, un evento de «todo el día». No tiene hora ni zona (el 1 de mayo de 1990 es el mismo 1 de mayo en Tokio y en Chicago). Guárdala en un tipo
DATE, o simplemente la cadena"1990-05-01"—y nunca la laves a través de un tipo de instante, o la moverás un día antes con tus propias manos. - Un compromiso local futuro — «las 9:00 del día 3 en Berlín, el año que viene», una reunión semanal. El offset que aplique entonces puede cambiar cuando cambien las reglas de DST de esa zona, así que guarda la hora local más el nombre de zona IANA (
Europe/Berlin), no un offset congelado. Si se repite, guarda también la regla de recurrencia (semanal, mensual —unaRRULEde RFC 5545) junto a la hora local y la zona. - Una duración o intervalo — un TTL de caché, un timeout, los «30 días» de «expira en 30 días». Eso es una cantidad, no un momento —guarda un entero (segundos o milisegundos, con la unidad en el nombre de columna o la documentación), o, si en realidad es un momento de expiración, guarda ese instante de expiración. Y ten presente una distinción: una duración fija (30 × 24 horas) es un conteo de segundos, pero un período de calendario («un mes», «30 días de calendario») lleva semántica de calendario y no debe aplanarse a un número fijo de segundos —puede cruzar un límite de DST o un mes corto—.
Dicho de otro modo: la equivalencia limpia «dos notaciones, un valor» solo se sostiene para el instante. Los otros tres tipos también tienen opciones de representación —un DATE frente a una cadena, un entero frente a una duración ISO 8601 como P30D, un objeto estructurado para un evento recurrente—, pero esas formas no son grafías intercambiables de un mismo valor como sí lo son el timestamp y la cadena ISO de un instante. Equivocar el tipo hace mucho más daño —y más callado— que elegir la notación equivocada para un instante.
Para un instante, ambas respuestas son correctas
Ahora acotemos a los instantes. Un hecho disuelve la mitad de la discusión al instante: un timestamp Unix entero y una cadena ISO 8601 con offset son dos notaciones del mismo instante, ambas ancladas a UTC. No son dos «horas» rivales —son dos grafías de la misma información, una escrita como número y otra como texto (justo la capa de notación de las cuatro):
1700000000 timestamp Unix (segundos)
2023-11-14T22:13:20Z el mismo instante, escrito en ISO 8601
Ambas líneas describen el mismo momento. Así que «entero o cadena» no es una pregunta de corrección —cualquiera de las dos fija el instante sin ambigüedad—. Es una concesión de ingeniería: legibilidad, tamaño, velocidad de comparación y orden, portabilidad entre lenguajes. Desmenuzaremos esa concesión abajo. Pero primero, mantén fuera la opción que falla cuando necesitas un instante, porque es la fuente real de los bugs.
La tercera respuesta peligrosa cuando necesitas un instante: una hora local sin zona
Una hora de reloj de pared local no está mal en sí misma —«abre a las 9:00 todos los días» es legítimamente una hora local sin zona, y un LocalDateTime puede ser una hora de pared que guardas a propósito—. Se vuelve el bug en el momento en que la tratas como un valor que debe fijar un único instante. Esa es la tercera opción que casi nadie eligió a propósito, pero que entra por un valor por defecto: una hora de reloj de pared local sin offset alguno.
2026-07-15 17:00:00 ← ¿las 17:00 en qué zona? Nadie lo sabe. No hay información suficiente para identificar un instante.
La cadena parece inofensiva, pero ha tirado la información necesaria para decir qué instante es. El mismo texto significa un momento distinto en Nueva York, en Shanghái y en UTC. En cuanto cae en la base de datos y se lee de vuelta, alguna zona por defecto tiene que aportar la mitad que falta —y ese defecto suele ser la zona del propio runtime (que es precisamente el caldo de cultivo de los bugs de «un día de desfase» y «unas horas de desfase»).
Esta trampa es especialmente fácil de pisar a nivel de tipo de base de datos:
- El
DATETIMEde MySQL no lleva semántica de zona —guarda y devuelve un valor de reloj de pared a secas—; mientras queTIMESTAMPconvierte a y desde UTC usando la zona de sesión (no de forma incondicional), y su rango tiene un tope, que termina en 2038-01-19 03:14:07 UTC. - El
timestamp without time zonede PostgreSQL también guarda un valor de reloj de pared a secas; solotimestamptzlo trata como un instante real (y aun así lo normaliza a UTC —no conserva el offset ni el nombre de zona de entrada). Esewithout time zonedel nombre suele leerse mal como «por defecto UTC» —en realidad significa «sin zona, semántica sin decidir». - La capa de aplicación —el
datetime«naive» de Python, unLocalDateTimede Java sin offset— es la misma trampa con otra cara.
La regla es simple, y recorre todo este grupo: cuando guardes un instante, guarda siempre su ancla —o un Z u offset explícito en la notación, o un tipo de columna que gestione correctamente la zona horaria—. Una hora de pared sin ancla no es un instante; es un acertijo por resolver.
La concesión de ingeniería: entero vs cadena ISO con offset
Una vez confirmado que es un instante y atada su ancla, lo que queda es la elección sobre la que sí puedes tener una preferencia legítima. Cada notación hace concesiones distintas:
| Timestamp Unix entero | Cadena ISO 8601 con offset | |
|---|---|---|
| Tamaño | Pequeño (BIGINT suele ser 8 bytes) | Mayor (~20–30 bytes de texto) |
| Comparar / rango / índice | Aritmética entera nativa, rápida | Necesita normalización para ser fiable |
| Legible por humanos | No — hay que convertir para saber cuándo | Sí — se lee de un vistazo |
| Autodescriptivo | No — segundos o milis es una convención | Sí — offset y precisión incorporados |
| Orden | Numérico, ordenado por naturaleza | Orden de texto = orden de tiempo solo tras normalizar a Z UTC con el mismo formato y precisión |
| Precisión | Fijada por la unidad (segundos truncan a segundos) | Arbitraria — añade o quita fracciones de segundo |
| Entre lenguajes / JSON | Necesita una convención de unidad | JSON no tiene tipo fecha; una cadena RFC 3339 es la opción de facto |
Dos frases resumen la tabla. El entero es compacto, comparable, rápido, a costa de ser ilegible y de exigir disciplina con su unidad —entrega segundos a algo que espera milisegundos y lanzas la fecha de vuelta a 1970—. La cadena ISO con offset es autodescriptiva, portable, legible de un vistazo, a costa de más espacio, un paso de normalización antes de comparar, y de ser fácil de escribir por error en la forma de pared sin zona —la tercera respuesta de la sección anterior—.
El tercer camino con menos esfuerzo: que el tipo de columna recuerde «esto es un instante»
Buena parte de esa concesión puede delegarse en la propia base de datos. Muchas bases de datos relacionales ofrecen un tipo nativo de «instante con zona» —úsalo en vez de inventar el tuyo con un entero a secas o una cadena a secas—. Pero el comportamiento exacto varía —algunos tipos normalizan a UTC, otros conservan un offset, y algunas bases de datos no tienen ningún tipo nativo consciente de la zona—, así que consulta la documentación de tu base de datos:
- El
timestamptzde PostgreSQL guarda el instante como UTC —no conserva el offset ni el nombre de zona de entrada. Escribes un valor con offset y lo normaliza al instante; lo lees y lo representa según elTimeZonede la sesión. Obtienes el instante correcto del entero con una interfaz legible. - En MySQL, o usas
DATETIMEcon una convención de toda la aplicación de que «todos los valores son UTC» (esquiva 2038, pero la disciplina corre de tu cuenta), o usasTIMESTAMPsabiendo que convierte según la zona de sesión y que tiene el techo de 2038. - En la capa de API / JSON, usa RFC 3339 —el subconjunto de internet más estricto de ISO 8601— porque JSON no tiene ningún tipo fecha, así que una cadena RFC 3339 es la forma más directa e interoperable de llevar el offset con el valor.
Fíjate en algo que a menudo se confunde con una contradicción pero no lo es: la columna guarda un «instante» y lo serializas a JSON como cadena ISO. Eso no es un conflicto —es que “guárdalo como UTC” y “dame una cadena ISO” viven en capas distintas—. El almacenamiento se ocupa de anclar el instante a una referencia; el transporte se ocupa de un texto que ambos lados puedan parsear. El mismo instante, capas distintas, cada una con su notación.
Unos cuantos detalles que no hay que olvidar
Con la dirección grande resuelta, quedan detalles que hacen tropezar en los bordes:
- La precisión muerde. Un entero que cuenta segundos no puede guardar milisegundos ni microsegundos —si necesitas precisión de subsegundo, sube la unidad (milis, micros) o usa una cadena o tipo de columna que pueda expresar fracciones de segundo—. No trunques en silencio esperando recuperarlo.
- El «superpoder» del orden tiene una condición. «Las cadenas ISO ordenan como texto» solo se cumple una vez que están normalizadas primero —mismo formato, misma precisión, e idealmente todas convertidas a
ZUTC—. Mezcla offsets y el orden de texto deja de ser el orden de tiempo. - Guarda una expiración como un momento, no como una duración. Para «expira 30 días después de crearse», guarda ese instante de expiración, no el número «30» con una nota —la comparación y las consultas siguen siendo triviales—. En cuanto a en qué día de calendario cae esa expiración, no lo calcules con «sumar 30 × 86400 segundos» (eso deriva con el DST y la longitud de los meses) —encarga el «cuántos días entre» y «qué fecha es dentro de 30 días» a una calculadora de fechas—.
- Un evento local futuro guarda un nombre de zona, no un offset. Mencionado arriba, pero vale la pena clavarlo de nuevo:
+02:00es un resultado que las reglas calculan para un instante, y cambia;Europe/Berlines la zona-con-reglas en sí, y eso es lo que un compromiso futuro debe guardar.
Al depurar, si tienes un entero a secas y no sabes si son segundos o milisegundos ni en qué día cae, échalo a un conversor de timestamps Unix y léelo de las dos formas; si quieres ver cómo se lee un instante guardado en las zonas de tus usuarios, un conversor de zonas horarias las alinea lado a lado.
Una tabla de decisión
Condensar todo lo anterior en una tabla de «guarda esto» cubre casi todos los casos:
| Lo que guardas | Guarda esto |
|---|---|
Un momento que ocurrió (created_at, logs, auditoría) | Un instante — un tipo de columna nativo consciente de zona, o un timestamp entero / cadena ISO anclada con Z |
| Una fecha de calendario (cumpleaños, fecha de factura, vencimiento) | Un tipo DATE o la cadena "1990-05-01" — nunca un tipo de instante |
| Un compromiso local futuro («las 9:00 en Berlín el año que viene») | Hora local más el nombre de zona IANA (Europe/Berlin) — no un offset a secas |
| Una duración / intervalo (TTL, timeout) | Duración fija: un entero con la unidad explícita. Período de calendario: conserva la semántica de calendario (P30D o un período estructurado). Expiración: el instante de expiración |
| Una hora que envías a otro sistema | Una cadena ISO RFC 3339 (JSON no tiene tipo fecha) |
La lista de cierre
La próxima vez que estalle «timestamp o cadena ISO», no empieces la pelea por entero contra cadena. Pregunta esto en orden:
- ¿Qué tipo de valor es? ¿Instante, fecha de calendario, compromiso local futuro o duración? La equivalencia «dos notaciones, un valor» es solo del instante; para los otros tres, equivocar el tipo es peor que equivocar la notación.
- ¿Lleva su ancla? Un instante debe llevar un
Z/offset, o vivir en un tipo de columna que gestione correctamente la zona horaria. Un valor de pared a secas sin offset no es un instante; es un acertijo. - Entero o cadena — elige por la concesión. ¿Quieres compacto, comparable, rápido? Entero (pero fija la unidad). ¿Quieres autodescriptivo, portable, legible? ISO con offset (pero normaliza antes de comparar). Si hay un tipo de columna nativo consciente de zona, úsalo y quédate con ambos.
- ¿Están alineados los detalles? Suficiente precisión (sin subsegundos truncados), normalización antes de ordenar, una expiración guardada como momento y no como duración, un evento futuro guardado como nombre de zona y no como offset.
Bajo los cuatro pasos está la línea que recorre todo el grupo: primero di si es un instante o alguna otra cosa, dale al instante su ancla, y solo entonces discute la notación. Acierta el orden y «timestamp o cadena ISO» pasa de ser una riña sin ganador a una decisión de ingeniería con una concesión clara que puedes explicar en cualquier momento.