Fecha, hora y zona horaria en aplicaciones empresariales — de las trampas de DateTime al principio de UTC y el diseño de pruebas

· Actualizado el: · · C#, .NET, .NET Framework, Windows, Zona horaria, Procesamiento de fecha y hora, Pruebas, Operación, Consultoría técnica

«Al trasladar el servidor a una VM en la nube, todas las horas de los informes se desplazaron 9 horas», «solo en los equipos distribuidos en la sucursal en el extranjero, la fecha del parte diario aparece como la del día anterior», «el proceso batch de agregación nocturna parece haberse ejecutado dos veces en un día concreto». Las consultas relacionadas con la fecha, la hora y las zonas horarias suelen llegar con esta forma: «justo al cambiar de entorno». Y eso que no se ha modificado ni una sola línea de código.

Es habitual pensar que «como nuestra aplicación es exclusivamente para uso en Japón, las zonas horarias no nos afectan», pero en la práctica muchas de las consultas surgen precisamente en ese tipo de aplicaciones de uso exclusivamente nacional. Las VM y los contenedores en la nube suelen aprovisionarse con la configuración en UTC, y las bibliotecas de proveedores extranjeros o las API web de servicios SaaS devuelven marcas de tiempo en UTC o con desfase incluido. Aunque la propia aplicación crea que vive únicamente en la hora de Japón, al otro lado de sus límites ya reina el mundo de UTC. Cuando los valores se intercambian sin dejar explícito «respecto a qué referencia está esta hora», el problema se manifiesta de golpe como un desplazamiento de 9 horas el día en que se traslada el servidor o se migra a la nube.

En este artículo, partiendo de aplicaciones empresariales en .NET, repasamos de forma ordenada la trampa del Kind de DateTime y sus conversiones implícitas, cuándo usar DateTimeOffset en lugar de DateTime, el principio de «guardar y comunicar en UTC o con desfase, y convertir a hora local solo para mostrar», TimeZoneInfo y el horario de verano, el límite con la base de datos y, finalmente, el diseño de pruebas con TimeProvider. Llegamos incluso a convertir en una lista de verificación los puntos que comprobamos siempre en las revisiones de diseño.

Público objetivo de este artículo y cómo leerlo

El público objetivo son los desarrolladores que crean y mantienen aplicaciones empresariales en .NET (C#). Este artículo está pensado precisamente para quienes piensan que «como es de uso exclusivamente nacional, las zonas horarias no nos afectan». Los ejemplos de código están escritos en C# / .NET 8, pero se indica en cada caso qué partes también se pueden usar en .NET Framework.

Como es un artículo largo, primero incluimos una guía de lectura organizada por objetivo.

Objetivo Dónde leer
Aplicación de uso exclusivamente nacional y pequeña escala; quiero cubrir solo lo mínimo Capítulo 1 (conclusión) → capítulo 2 (la trampa del Kind) → capítulo 3 (principio de guardar en UTC) → capítulo 6 (límite con la base de datos). Con estos cuatro apartados se evita la mayoría de los problemas
Quiero identificar la causa de un desfase que está ocurriendo ahora mismo Capítulo 2, en particular el procedimiento de reproducción del apartado 2.1 → «Código que detectar con grep en la revisión» del capítulo 8
Tenemos sucursales en el extranjero o integración con SaaS extranjero Además de lo anterior, el capítulo 4 (identificadores de zona horaria) y el capítulo 5 (horario de verano)
Quiero reproducir en pruebas errores de cambio de año o de fin de mes Apartado 7.2 (TimeProvider)
Solo quiero los criterios de revisión para un diseño nuevo La lista de verificación del capítulo 8

Terminología que conviene tener clara de antemano

Antes de continuar, resumimos los términos que aparecen en el cuerpo del artículo, a menudo como siglas.

Término Significado
UTC (Coordinated Universal Time) Tiempo universal coordinado. Es la referencia horaria común a nivel mundial; la hora de Japón (JST) equivale a UTC+9
ISO 8601 Norma internacional que define la representación en texto de la fecha y la hora, con un formato como 2026-07-03T13:30:00+09:00
IANA tz database Base de datos que reúne las definiciones de zonas horarias de todo el mundo y su historial de revisiones pasadas. La gestiona la IANA (Internet Assigned Numbers Authority), que define identificadores como Asia/Tokyo (apartado 4.1)
ICU (International Components for Unicode) Biblioteca de referencia para el procesamiento de internacionalización. Desde .NET 5, la resolución de la referencia cultural y de la zona horaria se delega en ella (apartado 4.1)1
NLS (National Language Support) API de internacionalización que Windows tiene desde antes de ICU. Es posible ejecutar .NET en modo NLS, pero en ese caso no se pueden resolver los identificadores IANA1
DST (Daylight Saving Time) Horario de verano. Japón no lo tiene actualmente, pero aparece al tratar con sucursales en el extranjero o con SaaS extranjero (capítulo 5)
Kind Atributo de DateTime que indica «respecto a qué referencia está». Tiene tres valores posibles: Utc, Local y Unspecified (capítulo 2)

1. Conclusión principal

En una frase: «obtenga, guarde y transmita en UTC (o con desfase), y convierta a hora local solo una vez, justo antes de mostrarla en pantalla». A continuación se detalla en qué consiste este principio y qué ocurre cuando se incumple.

  • La causa raíz de casi todos los incidentes de fecha y hora es una sola: un valor que no lleva la información de «respecto a qué referencia está esta hora» cruza un límite (base de datos, API, archivo). El síntoma no aparece al cambiar el código, sino al cambiar el entorno.
  • DateTime tiene un atributo llamado Kind (Utc / Local / Unspecified), cuyo valor por defecto es Unspecified. Existe una interpretación implícita asimétrica: ToLocalTime interpreta Unspecified como UTC, mientras que ToUniversalTime lo interpreta como hora local; esta es la fuente típica del «desfase de 9 horas».2
  • Para código nuevo, el tipo por defecto debe ser DateTimeOffset. La guía oficial también indica explícitamente «considerarlo como el tipo de fecha y hora por defecto en el desarrollo de aplicaciones».3
  • El principio es «guardar y transmitir en UTC o con desfase, y usar hora local solo para mostrar». Al convertir a texto, escriba con el formato de ida y vuelta “o” conforme a ISO 8601, y léalo de nuevo con DateTimeStyles.RoundtripKind.4
  • La conversión de zona horaria se realiza con TimeZoneInfo. Desde .NET 6, se pueden usar tanto el ID de IANA (Asia/Tokyo) como el ID de Windows (Tokyo Standard Time), y también existen API de conversión mutua.5 Sin embargo, en Windows la resolución de IANA depende de ICU, y falla en versiones antiguas de Windows Server o en configuraciones de globalización invariable (capítulo 4).1
  • Aunque Japón no tenga horario de verano, en cuanto interviene un equipo de una sucursal en el extranjero, la integración con un SaaS extranjero o un servidor configurado en UTC, se topa con las «horas que no existen y horas ambiguas» del horario de verano (DST).6
  • Deje de escribir DateTime.Now directamente en el código y inyecte TimeProvider (estándar en .NET 8; en entornos antiguos, Microsoft.Bcl.TimeProvider) para diseñar el sistema de modo que la hora se pueda mover libremente en las pruebas.7

2. El Kind de DateTime — el significado de sus tres valores y los errores por conversión implícita

Además del valor de fecha y hora (Ticks), DateTime tiene un único atributo adicional llamado Kind. Puede tomar tres valores, y el valor por defecto es Unspecified.2

Kind Significado Principales orígenes
Utc Hora con referencia UTC DateTime.UtcNow, resultado de ToUniversalTime()
Local Referencia a la zona horaria local de la máquina de ejecución DateTime.Now, resultado de ToLocalTime()
Unspecified Se desconoce la referencia (valor por defecto) new DateTime(...), DateTime.Parse (en la mayoría de los casos), lectura desde la base de datos

Lo importante es que, si se escribe código de la manera habitual, todo termina lleno de valores Unspecified. Tanto los valores creados con el constructor como los obtenidos al analizar una cadena de texto o al leer desde la base de datos son, por defecto, de referencia «desconocida». Eso en sí mismo no es un problema. El problema es que los métodos de conversión asumen silenciosamente una referencia para estos valores Unspecified.2

Llamada Kind=Utc Kind=Local Kind=Unspecified
ToUniversalTime() Devuelve el valor tal cual Convierte a UTC Convierte a UTC interpretándolo como hora local
ToLocalTime() Convierte a hora local Devuelve el valor tal cual Convierte a hora local interpretándolo como UTC

El punto clave es esta asimetría: el mismo valor Unspecified se interpreta como «hora local» o como «UTC» según el método que se llame. En código se ve así.

// Valor leído de la base de datos. En muchas rutas, el Kind termina en Unspecified
var fromDb = new DateTime(2026, 7, 3, 9, 0, 0);

// Si la máquina de ejecución está en JST (UTC+9):
Console.WriteLine(fromDb.ToLocalTime());      // 18:00 -- se interpreta como UTC y se suman 9 horas
Console.WriteLine(fromDb.ToUniversalTime());  // 00:00 -- se interpreta como hora local y se restan 9 horas

El incidente ocurre exactamente así. Si a un valor 09:00 (Unspecified) guardado en la base de datos en hora de Japón se le aplica ToLocalTime() como una «conversión de precaución» antes de mostrarlo, se interpreta como UTC y se convierte en 18:00. A la inversa, si en algún punto del recorrido hay duplicada una conversión pensada como «alinear a UTC antes de guardar», las 9 horas se restan dos veces. Lo más complicado es que este comportamiento depende de la configuración de zona horaria de la máquina de ejecución. En el equipo de desarrollo (JST) el desfase es de 9 horas, mientras que en un servidor configurado en UTC es de 0 horas, lo cual da lugar a consultas del tipo «en mi entorno no se reproduce». El caso mencionado al principio, «al trasladar el servidor, la hora se desfasó 9 horas», suele responder exactamente a esta estructura.

2.1 Cómo reproducir el «desfase de 9 horas» en su propio equipo — un mini experimento de 5 minutos

La forma más rápida de dejar atrás el «en mi equipo no se reproduce» es cambiar la zona horaria del equipo de desarrollo y ejecutar el mismo código. Se hace en 5 minutos.

Primero, prepare la siguiente aplicación de consola.

// .NET 8 / C#. Valor Unspecified que simula "las 9:00 hora de Japón" leídas de la base de datos
var fromDb = new DateTime(2026, 7, 3, 9, 0, 0);

Console.WriteLine($"Zona horaria del SO : {TimeZoneInfo.Local.Id}");
Console.WriteLine($"Kind                : {fromDb.Kind}");
Console.WriteLine($"ToLocalTime()       : {fromDb.ToLocalTime():HH:mm}");
Console.WriteLine($"ToUniversalTime()   : {fromDb.ToUniversalTime():HH:mm}");

El procedimiento es el siguiente. Para cambiar la zona horaria se utiliza tzutil, la herramienta estándar de Windows (si el cambio se rechaza, ejecute el símbolo del sistema como administrador).

  1. Anote el ID de la zona horaria actual con tzutil /g (para poder restaurarlo después). En un equipo de desarrollo configurado en Japón aparecerá Tokyo Standard Time.
  2. Ejecute la aplicación tal cual y anote el resultado.
  3. Cambie la zona horaria a UTC con tzutil /s "UTC" y reinicie la aplicación para ver el mismo resultado. Como TimeZoneInfo.Local se guarda en caché dentro del proceso, no cambiará si deja la aplicación en ejecución.
  4. Restaure el valor original con tzutil /s "Tokyo Standard Time".

Con el mismo código y el mismo valor de entrada, el resultado cambia así.

Zona horaria de la máquina de ejecución ToLocalTime() ToUniversalTime()
Tokyo Standard Time (UTC+9) 18:00 (se interpreta como UTC y se suman 9 horas) 00:00 (se interpreta como hora local y se restan 9 horas)
UTC 09:00 (no varía) 09:00 (no varía)

Esta es la verdadera naturaleza de «no se reproduce en mi equipo de desarrollo». El código que aplica una conversión a un valor Unspecified cambia de resultado según un estado externo: la configuración de la máquina de ejecución, de modo que en un equipo de desarrollo en JST el desfase es de 9 horas muy visibles, mientras que en una VM en la nube configurada en UTC no hay ningún desfase en absoluto. A la inversa, si se traslada a un servidor en UTC datos que se habían estado guardando en hora local, el desfase que hasta entonces se cancelaba a sí mismo sale a la luz.

Este experimento no se puede sustituir por FakeTimeProvider. Lo que FakeTimeProvider.SetLocalTimeZone reemplaza es únicamente la propiedad LocalTimeZone de ese TimeProvider; el TimeZoneInfo.Local de todo el proceso no cambia.7 Como el código anterior llama a ToLocalTime() / ToUniversalTime(), que consultan este último, aunque se inyecte el objeto simulado (fake), el resultado será el de la configuración de la máquina de CI, sin más.

Dicho de otro modo, esta es también la razón para desterrar ToLocalTime() del código de negocio. Los métodos que leen la configuración de todo el proceso no se pueden sustituir desde las pruebas. Si se quiere incorporar el tratamiento de zonas horarias a las pruebas de regresión, hay que recibir la zona de destino como argumento.

// .NET 8 / C#. La zona de destino se recibe desde fuera. Si se ha inyectado un
// TimeProvider, basta con pasar provider.LocalTimeZone
static DateTime JstWallClockToUtc(DateTime wallClock, TimeZoneInfo zone)
    => TimeZoneInfo.ConvertTimeToUtc(
           DateTime.SpecifyKind(wallClock, DateTimeKind.Unspecified), zone);

// Prueba: el resultado es el mismo sin importar la configuración de zona horaria de la máquina
var tokyo = TimeZoneInfo.FindSystemTimeZoneById("Tokyo Standard Time");
Assert.Equal(
    new DateTime(2026, 7, 3, 0, 0, 0, DateTimeKind.Utc),          // 9:00 JST = 0:00 UTC
    JstWallClockToUtc(new DateTime(2026, 7, 3, 9, 0, 0), tokyo));

FakeTimeProvider, del apartado 7.2, solo surte efecto cuando el código bajo prueba pasa por provider.GetLocalNow() o provider.LocalTimeZone. No llega al código que llama directamente a DateTime.Now o ToLocalTime(). Si lo que se quiere confirmar es que la propia configuración de la máquina cambia el resultado, el procedimiento con tzutil descrito arriba sigue siendo, al final, el más fiable.

2.2 Diferencias con DateTimeOffset y cuándo usar cada uno

DateTimeOffset siempre lleva, además de la fecha y la hora, el desfase respecto a UTC (por ejemplo, +09:00), de modo que el valor por sí solo determina de forma inequívoca a qué instante del mundo corresponde. Para usos de «registro de un momento», como los registros de log, las horas de las transacciones o el registro de eventos del sistema, la guía oficial indica explícitamente considerar DateTimeOffset como el tipo de fecha y hora por defecto.3 Como de entrada no existe margen para depender de una interpretación implícita del Kind, la mayoría de los incidentes tratados en este artículo dejan de ser posibles de forma estructural.

Sin embargo, no es una solución universal. Tenga en cuenta que lo que tiene DateTimeOffset es un desfase, no una zona horaria. No permite saber si +09:00 corresponde a Japón o a Corea, y tampoco lleva las reglas de ajuste del horario de verano.3 Si lo que se quiere reproducir es «el comportamiento del reloj de pared de un lugar concreto», hay que combinarlo con TimeZoneInfo, del que se habla más adelante. A continuación, una tabla con los distintos usos.

Tipo Información que contiene Uso recomendado Notas
DateTimeOffset Fecha y hora + desfase respecto a UTC Registro del momento en que ocurre algo, logs, límites de API Opción por defecto en código nuevo3
DateTime (con Kind=Utc) Solo fecha y hora Cálculos internos, compatibilidad con código existente La gestión del Kind corre enteramente por cuenta propia
DateOnly / TimeOnly Solo fecha / solo hora Fecha de negocio, horario laboral, hora de cierre No disponible en .NET Framework3
TimeSpan Intervalo de tiempo Tiempo transcurrido, diferencia entre dos instantes  
TimeZoneInfo Definición de la zona horaria (incluidas las reglas de ajuste) Conversión, determinación del horario de verano Capítulo 4

Reescribir todo el código existente basado en DateTime para pasarlo a DateTimeOffset suele ser poco realista, así que la solución intermedia que solemos adoptar en nuestros proyectos de modernización es «unificar el uso interno y el almacenamiento con DateTime de Kind=Utc, y usar DateTimeOffset o una cadena en formato “o” en los límites (API, serialización)». En cualquiera de los dos casos, el principio del siguiente capítulo es la base sobre la que se construye todo.

3. Principio — guardar y comunicar en UTC o con desfase, y usar hora local solo para mostrar

El principio de diseño para el tratamiento de fechas y horas se resume en tres líneas.

  1. Obtenga el momento en que ocurre un evento con DateTime.UtcNow o DateTimeOffset.UtcNow, y manéjelo siempre en UTC (o con desfase)
  2. Al cruzar un límite (base de datos, API, archivo, registro de Windows), deje explícitos el formato y la referencia como parte de la especificación
  3. Convierta a hora local una sola vez: justo antes de mostrarla en pantalla o en un informe

Si se representa en un diagrama, el único punto donde se permite la conversión es uno solo.

no se debe hacerSe produce / se obtieneDateTimeOffset.UtcNowTimeProvider.GetUtcNow()Interior de la aplicaciónse conserva siempre en UTCla comparación y el orden también se hacen en UTCBase de datosse guarda en UTC en datetime2el nombre de columna es CreatedAtUtcAPI, archivos, logsformato de ida y vuelta o de ISO 86012026-07-03T04:30:00.0000000ZConversión una sola vez, justo antes de mostrarTimeZoneInfo.ConvertTimeFromUtcel destino se decide por la configuración del usuario o de la sucursalPantalla / informehora del reloj de pared de ese lugarGuardar o enviar en hora localel significado del valor depende de la configuración del SOse desfasa 9 horas el día del traslado

Un diseño que guarda en hora local hace que «el significado del valor» dependa de un estado externo: la configuración del sistema operativo del servidor. Mientras la aplicación funcione en un servidor local configurado en Japón, el problema no se ve, pero basta con uno solo de estos cambios para que el significado cambie: el traslado a una VM en la nube, una configuración de recuperación ante desastres (DR) en una región extranjera, o una diferencia de configuración entre el entorno de desarrollo y el de producción. Con almacenamiento en UTC, el significado del valor es el mismo en cualquier entorno. La conversión para mostrar los datos se realiza según la configuración del usuario (o la zona horaria de la sucursal registrada en el maestro de usuarios), de modo que los mismos datos, vistos desde Tokio o desde Berlín, se muestran correctamente en la hora del reloj de pared de cada lugar.

3.1 La representación textual en los límites: ISO 8601 / formato “o”

Cuando se cruza un límite mediante texto (JSON, CSV, logs, archivos de configuración), utilice el formato de ida y vuelta “o”, conforme a ISO 8601. El formato “o” conserva en la cadena el Kind de DateTime y el desfase de DateTimeOffset, y si se analiza especificando DateTimeStyles.RoundtripKind, se puede recuperar el valor original.4

using System.Globalization;

// Escritura: 2026-07-03T13:30:00.0000000+09:00
DateTimeOffset now = DateTimeOffset.Now;
string s = now.ToString("o", CultureInfo.InvariantCulture);

// Lectura: se restaura conservando el desfase
var restored = DateTimeOffset.Parse(
    s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);

Utilice siempre CultureInfo.InvariantCulture junto con este formato. Si se deja la referencia cultural por defecto, la representación del año cambia en equipos que funcionan con una referencia cultural distinta del calendario gregoriano, como la era japonesa. Por el contrario, lo que nunca debe hacerse es guardar o transmitir con un formato que no lleve información de referencia, como "yyyy/MM/dd HH:mm". Quien reciba esta cadena de texto solo puede adivinar «respecto a qué referencia está», y esa suposición falla en cuanto cambia el entorno. Además, como la representación de fecha y hora por defecto de System.Text.Json también sigue el estándar ISO 8601, en los límites JSON es seguro simplemente confiar en el comportamiento por defecto.

La idea de «dejar explícito en la especificación el formato de los datos que cruzan un límite» no se limita a las fechas y horas. Desarrollamos exactamente el mismo argumento para la codificación de caracteres y los saltos de línea en «Codificación de caracteres y saltos de línea en Windows». Lo implícito en un límite se rompe de la misma forma, aunque el tipo de dato sea distinto.

3.2 Distinguir la marca de tiempo de la «fecha de negocio»

Aquí hay otra distinción necesaria para resolver el caso mencionado al principio: «solo en la sucursal en el extranjero, la fecha del parte diario aparece como la del día anterior». La marca de tiempo (un instante único en el mundo) y la fecha de negocio (una etiqueta como «el parte diario del 3 de julio») son cosas distintas. Si se guarda la fecha de negocio como un DateTime correspondiente a la medianoche y se le aplica una conversión a UTC, las 00:00 del 3 de julio en UTC+9 se convierten en las 15:00 del 2 de julio en UTC, de modo que, en el momento en que se extrae solo la parte de la fecha, esta retrocede al día anterior. Este es el mecanismo típico que produce el problema.

La fecha de negocio debe guardarse como DateOnly (o, en .NET Framework, como una cadena en formato yyyy-MM-dd o como un valor de año, mes y día), y la especificación debe decidir «en qué zona horaria se corta la fecha». Si en la especificación está escrito, por ejemplo, «la fecha del parte diario se basa en la hora local de la sucursal» o «el cierre se basa en la hora JST de la sede central», la implementación se limita a convertir UtcNow a la zona horaria correspondiente y cortar la fecha a partir de ahí. Si no está escrito en la especificación, eso significa que la configuración de la máquina de quien lo implementó se ha convertido, de facto, en la especificación.

Al trasladarlo al diseño de tablas, ambos conceptos se diferencian ya desde el tipo de columna. Tomando SQL Server como ejemplo, quedaría así (los detalles de los tipos se ven en el apartado 6.1).

Uso Ejemplo de columna (SQL Server) Tipo en .NET Objetivo
Marca de tiempo (momento en que ocurre) created_at_utc datetime2(3) NOT NULL DateTime (Kind=Utc) Guardar en UTC e incorporar la referencia al nombre de la columna (apartado 6.3)
Marca de tiempo (se requiere reproducir la hora local) signed_at datetimeoffset(3) NOT NULL DateTimeOffset Conservar incluso el desfase del momento de entrada
Fecha de negocio (etiqueta de parte diario o de cierre) report_date date NOT NULL DateOnly No lleva ni hora ni zona horaria
Zona horaria de referencia para cortar la fecha site_time_zone_id varchar(64) NOT NULL string (se resuelve a TimeZoneInfo) Guardarla como ID de IANA en el maestro de sucursales (apartado 4.1)
Hora de cierre / horario laboral closing_time time(0) NOT NULL TimeOnly No asociar una fecha a «todos los días a las 17:30»

En resumen, no use un tipo que lleve hora en la columna de fecha de negocio. En el momento en que report_date se define como datetime2 y se le asigna la hora 00:00, resucita la ruta hacia el desfase al día anterior descrita al principio de este apartado. En cambio, con el tipo date de entrada no existe siquiera la posibilidad de que pase por una conversión a UTC.

4. Conversión de zona horaria — TimeZoneInfo y los sistemas de identificadores

La conversión de zona horaria se realiza con ConvertTimeFromUtc / ConvertTimeToUtc / ConvertTime de TimeZoneInfo. Hay que tener en cuenta que estas API comprueban la coherencia entre el Kind de DateTime y la zona horaria de origen de la conversión. Por ejemplo, si se pasa un valor con Kind=Utc indicando «el origen de la conversión es Tokio», se produce un ArgumentException.8 Es decir, el código que gestiona el Kind de forma descuidada ni siquiera puede llamar correctamente a las API de conversión de zona horaria. Aquí es donde conecta directamente lo tratado en el capítulo 2.

4.1 Los identificadores de zona horaria de Windows y de IANA

Existen dos sistemas de identificadores para especificar una zona horaria.

  ID de Windows ID de IANA
Ejemplo (Japón) Tokyo Standard Time Asia/Tokyo
Ejemplo (Alemania) W. Europe Standard Time Europe/Berlin
Gestionado por Windows (registro) IANA tz database
Uso principal API de Windows, .NET (Framework) Linux, SaaS extranjero, API web, otros lenguajes

En la época de .NET Framework solo se podía usar el ID de Windows, y existía el problema de tener que mantener una tabla de conversión: llegaba Asia/Tokyo desde una API web, pero no se podía pasar directamente a FindSystemTimeZoneById. Desde .NET 6, TimeZoneInfo.FindSystemTimeZoneById acepta ambos tipos de ID, y resuelve automáticamente convirtiendo el que no exista en el sistema. También se han añadido TryConvertIanaIdToWindowsId / TryConvertWindowsIdToIanaId para cuando se quiere hacer la conversión de forma explícita.5

// .NET 6+ : el ID de IANA funciona directamente (con el ID de Windows "Tokyo Standard Time" da el mismo resultado)
var tokyo  = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");

DateTime utc = DateTime.UtcNow;
Console.WriteLine(TimeZoneInfo.ConvertTimeFromUtc(utc, tokyo));   // Hora del reloj de pared de Tokio
Console.WriteLine(TimeZoneInfo.ConvertTimeFromUtc(utc, berlin));  // Hora del reloj de pared de Berlín

// Conversión mutua entre los dos sistemas de ID (.NET 6+)
if (TimeZoneInfo.TryConvertWindowsIdToIanaId("Tokyo Standard Time", out var ianaId))
    Console.WriteLine(ianaId);  // Asia/Tokyo

Como pauta práctica, se recomienda unificar en el ID de IANA los identificadores de zona horaria que se guardan en el maestro de sucursales o en los archivos de configuración. El ID de IANA es el que funciona de forma común en la integración con SaaS extranjero, contenedores Linux y otros lenguajes, y en el lado de .NET, a partir de la versión 6, en principio se puede recibir directamente. Si queda alguna parte que funciona en .NET Framework, conviértalo al ID de Windows únicamente en ese límite. Además, como FindSystemTimeZoneById lanza TimeZoneNotFoundException cuando no encuentra el ID, detecte el error cuanto antes, ya sea en la pantalla donde se registra el ID en el maestro o en la validación al iniciar la aplicación.

Existe una condición previa importante: en Windows, la resolución del ID de IANA depende de la biblioteca ICU. En aplicaciones que se ejecutan en modo NLS o en modo de globalización invariable (InvariantGlobalization=true), el ID de IANA no se puede resolver, y TryConvertIanaIdToWindowsId también falla.1 Además, si se ejecuta .NET 6 en un entorno antiguo cuyo sistema operativo no incluye ICU (Windows Server 2019, versiones de Windows 10 anteriores a la 1809, etc.), se topa con la misma limitación a menos que se incluya ICU local a la aplicación (desde .NET 7 se ha cambiado para que ICU se use también en estos sistemas operativos9). Es decir, ocurre en la práctica que «en el equipo de desarrollo (Windows 11) Asia/Tokyo funciona, pero solo en el Server 2019 del cliente se produce TimeZoneNotFoundException». Si se adopta el ID de IANA en el maestro, aplique este conjunto de tres medidas: (1) confirmar que la resolución de IANA funciona con el sistema operativo y la versión de .NET del entorno de ejecución; (2) no habilitar a la ligera InvariantGlobalization en contenedores u otros entornos por motivos de tamaño; (3) como medida de seguridad, incluir en la validación de inicio una alternativa de reserva (fallback) que use TryConvertIanaIdToWindowsId para reducirlo al ID de Windows y reintentarlo.

5. Horario de verano (DST) — situaciones en las que afecta incluso a aplicaciones de uso exclusivamente nacional

Como Japón no tiene actualmente horario de verano, es habitual pensar que «el DST no nos afecta». Sin embargo, si se cumple cualquiera de las siguientes condiciones, sin duda lo afectará.

  • La aplicación se ejecuta en equipos de sucursales en el extranjero o de empleados en viaje al exterior (la zona horaria local del equipo tiene DST)
  • Se integra con SaaS o API web de proveedores extranjeros y recibe marcas de tiempo o programaciones basadas en la hora local
  • Los procesos de agregación o por lotes se ejecutan en servidores o VM de regiones extranjeras
  • Existe una agregación que cierra según la hora local de una sucursal en el extranjero (por ejemplo, «cierre a medianoche en cada sucursal»)

En las zonas horarias que tienen DST se generan dos tipos de horas anómalas el día del cambio: la hora que no existe (en primavera, el intervalo que se salta en el instante en que el reloj avanza; en Alemania, de 02:00 a 03:00 a finales de marzo) y la hora ambigua (en otoño, el intervalo que aparece dos veces al retroceder el reloj). En .NET se pueden determinar con TimeZoneInfo.IsInvalidTime / IsAmbiguousTime.6 Y las API de conversión como ConvertTimeToUtc lanzan ArgumentException si se les pasa una hora que no existe, e interpretan la hora ambigua como perteneciente a la hora estándar.8

var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");

// El 2026-03-29 es el día en que empieza el DST en Alemania. La hora local entre las 02:00 y las 03:00 no existe
var t = new DateTime(2026, 3, 29, 2, 30, 0);   // Kind = Unspecified

Console.WriteLine(berlin.IsInvalidTime(t));    // True
// TimeZoneInfo.ConvertTimeToUtc(t, berlin) produce ArgumentException

Si existe una ruta de entrada que consiste en «recibir una cadena de hora local, convertirla a UTC y guardarla», esta excepción queda latente como un error que solo se produce un día al año. Un punto de llegada realista consiste en comprobar IsInvalidTime en la fase de validación de entrada, y dejar explícito en la especificación que la hora ambigua se «trata como perteneciente a la hora estándar».

5.1 Las ejecuciones periódicas y el DST — el problema de ejecutarse dos veces o no ejecutarse

Las ejecuciones periódicas son otro punto clásico donde se sufre el impacto. Un trabajo que se ejecuta todos los días a las 02:30 hora local no tiene esa hora el día en que empieza el DST, y la tiene dos veces el día en que termina. Según la implementación del planificador, el comportamiento varía entre «se salta», «se inicia dos veces» o «se inicia con una hora de desfase», por lo que el procesamiento de agregación escrito bajo la premisa de «se ejecuta una sola vez al día» produce una agregación duplicada o datos faltantes. La solución es combinar tres medidas.

  • Basar la programación en UTC (o en una zona horaria sin DST). En los procesos por lotes en los que no es un requisito iniciarse en hora local, esto basta por sí solo para eliminar el problema
  • Hacer el procesamiento idempotente. Con un marcador de «ya ejecutado» del tipo «si la agregación del día en cuestión ya existe, se omite», el proceso no se rompe aunque se inicie dos veces
  • Guardar la clave de agregación como fecha de negocio (apartado 3.2), sin calcular la fecha a partir de la hora de inicio

El diseño de ejecuciones periódicas con el Programador de tareas (prevención de inicios múltiples, diagnóstico de fallos) se trata en detalle en «El Programador de tareas no ejecuta la tarea o termina con el código 0x1», y el diseño en el que el propio servicio residente mantiene un temporizador se trata en «Cómo crear y operar un servicio de Windows». Con cualquiera de los dos enfoques, es igualmente necesario dejar por escrito en la especificación la relación entre el DST y la programación.

6. El límite con la base de datos — SQL Server / SQLite / ORM

De todos los límites, la base de datos es donde ocurren más incidentes. Esto se debe a que muchos de los tipos de fecha y hora de las bases de datos no conservan «respecto a qué referencia está», de modo que la información del Kind o del desfase se pierde en el instante mismo en que se guarda el valor.

6.1 Los tipos de fecha y hora de SQL Server

Tipo Rango y precisión Información de referencia Recomendación para uso nuevo
datetime Desde el año 1753, precisión de aproximadamente 1/300 de segundo Ninguna Evitar (la documentación oficial indica explícitamente que no se recomienda para uso nuevo)10
datetime2 Desde el año 0001, precisión de hasta 100 nanosegundos Ninguna Recomendado: la opción principal para columnas que se guardan en UTC10
datetimeoffset Equivalente a datetime2 + desfase Conserva el desfase Adecuado para columnas donde se requiere reproducir la hora local10

datetime es un tipo de una generación anterior, con un redondeo poco fino y un rango limitado; la documentación oficial indica explícitamente «evitarlo en trabajos nuevos y usar datetime2 / datetimeoffset, entre otros».10 No es necesario forzar la migración del datetime de un esquema existente, pero no hay ningún motivo para elegirlo en una tabla nueva.

La elección entre guardar en datetime2 con UTC o usar datetimeoffset depende de si es necesario poder reproducir después el desfase del momento de entrada. Si existe un requisito de auditoría o de cumplimiento normativo que obligue a conservar «qué hora era en la hora local del usuario», use datetimeoffset; si basta con poder identificar el instante, es suficiente con datetime2 en UTC. Sin embargo, al igual que ocurre con DateTimeOffset, lo que tiene datetimeoffset es solo el desfase, no la zona horaria (con sus reglas de ajuste) en sí. Si también se necesita la zona, guarde el ID de IANA en una columna aparte.

6.2 SQLite no tiene un tipo de fecha y hora

SQLite no dispone de entrada de un tipo de almacenamiento para fecha y hora, y Microsoft.Data.Sqlite guarda DateTime / DateTimeOffset como TEXT.11 Con un formato de tipo ISO 8601 en TEXT, «si el formato y la zona horaria están unificados», ordenar la cadena de texto equivale a ordenar por hora; pero, dicho de otro modo, en el momento en que UTC y hora local se mezclan en una misma columna, tanto el ordenamiento como las búsquedas por rango se rompen silenciosamente. Si se usa SQLite, no queda más remedio que fijar como convención de la aplicación reglas del tipo «esta columna está en UTC, con este formato». Los aspectos prácticos de SQLite, incluidas las conexiones y las transacciones, están reunidos en «Cómo usar SQLite en aplicaciones empresariales con C#».

6.3 Precauciones con EF Core / Dapper — el Kind desaparece al leer

Al leer un DateTime desde un tipo que no conserva información de referencia (datetime2, TEXT de SQLite, etc.), naturalmente el Kind termina en Unspecified. De ahí nace el incidente de «se había unificado el almacenamiento en UTC, pero en algún punto se aplicaba ToUniversalTime() al valor leído, produciendo una doble conversión». La solución consiste en restaurar el Kind en el límite. Con EF Core se puede declarar de una sola vez mediante un conversor de valores (value converter).

// EF Core: declarar de una sola vez, en el modelo, que "esta columna está en UTC"
modelBuilder.Entity<Order>()
    .Property(o => o.CreatedAtUtc)
    .HasConversion(
        // Escritura: normalizar a UTC en el límite incluso los valores que no lo estén
        // (por ejemplo, si se cuela un DateTime.Now). Los Unspecified se convierten
        // interpretándolos como hora local
        v => v.Kind == DateTimeKind.Utc ? v : v.ToUniversalTime(),
        // Lectura: restaurar el Kind
        v => DateTime.SpecifyKind(v, DateTimeKind.Utc));

La normalización del lado de la escritura es, en definitiva, solo un seguro de último recurso. Como la conversión de un valor Unspecified depende de la configuración de zona horaria de la máquina de ejecución, en cuanto se encuentre código que introduzca DateTime.Now o valores Unspecified en la ruta de almacenamiento, corríjalo de inmediato. Entiéndalo como una estructura de dos capas: el seguro y la convención. Con Dapper o con ADO.NET puro, concentre en un único punto una capa de reasignación que pase por DateTime.SpecifyKind justo después del mapeo. Lo que también resulta eficaz es la convención de nomenclatura: basta con incorporar la referencia al nombre de la columna o de la propiedad (CreatedAtUtc, updated_at_utc) para que aumente considerablemente la probabilidad de que, en una revisión, alguien note que «aplicar ToUniversalTime() a este valor es raro». Y es que los nombres se leen más que la documentación.

7. Sincronización horaria y pruebas — w32time y TimeProvider

7.1 Cuestione la suposición de que el reloj de la máquina está en hora

Todo lo tratado hasta ahora partía de la premisa de que «el reloj de la máquina en sí es correcto», pero lo que mantiene ese reloj en hora es el servicio de hora de Windows (w32time). w32time se sincroniza mediante NTP con una fuente horaria en la red y, en un entorno de Active Directory, se sincroniza siguiendo la jerarquía de dominio. Es la base de mecanismos sensibles al desfase horario, empezando por la autenticación Kerberos.12

Esto tiene dos implicaciones para el diseño de aplicaciones. La primera es no basar la lógica de negocio en el reloj del PC cliente. Un equipo cuya sincronización se ha detenido puede desviarse tranquilamente varios minutos, así que el orden de los eventos y la determinación de la hora de cierre deben realizarse con la hora del servidor, dejando la hora del cliente como simple información de referencia. La segunda es que, ante una consulta del tipo «la hora está mal», antes de investigar la aplicación conviene comprobar el estado de sincronización del equipo con w32tm /query /status. Esto permite descartar esa causa en un minuto, antes de empezar a investigar un posible error en la aplicación.

7.2 Deje de escribir DateTime.Now directamente en el código — TimeProvider

El mayor obstáculo para probar el tratamiento de fechas y horas es DateTime.Now escrito directamente por todo el código. Aunque se quiera probar «la determinación del cierre de fin de mes», «el reinicio del número correlativo al cambiar de año» o «la programación del día de cambio de horario de verano», si no se puede fijar la hora actual, no se puede verificar hasta que llegue ese día.

Desde .NET 8 se incorporó la abstracción horaria estándar TimeProvider. Con una única abstracción se puede sustituir GetUtcNow(), GetLocalNow(), LocalTimeZone e incluso la creación de temporizadores. También en .NET Framework 4.6.2 o posterior y en .NET Standard 2.0 se puede usar el mismo tipo mediante el paquete NuGet Microsoft.Bcl.TimeProvider, de modo que se puede introducir incluso en código antiguo. La implementación para pruebas, FakeTimeProvider, se ofrece en el paquete Microsoft.Extensions.TimeProvider.Testing.7

public sealed class DailyReportService
{
    private readonly TimeProvider _clock;
    private readonly TimeZoneInfo _siteTimeZone;

    public DailyReportService(TimeProvider clock, TimeZoneInfo siteTimeZone)
    {
        _clock = clock;
        _siteTimeZone = siteTimeZone;
    }

    // Implementación que deja explícito que la fecha de negocio "se corta con la zona horaria de la sucursal" (apartado 3.2)
    public string GetReportDateKey()
    {
        var localNow = TimeZoneInfo.ConvertTime(_clock.GetUtcNow(), _siteTimeZone);
        return localNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
    }
}

En producción se pasa TimeProvider.System, y en las pruebas se puede fijar y avanzar la hora libremente con FakeTimeProvider.

[Fact]
public void La_fecha_de_negocio_cambia_correctamente_al_cruzar_el_ano()
{
    // Se inicia fijando la hora a las 23:30 de la víspera de Año Nuevo en JST
    var clock = new FakeTimeProvider(
        new DateTimeOffset(2026, 12, 31, 23, 30, 0, TimeSpan.FromHours(9)));
    var tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
    var svc = new DailyReportService(clock, tokyo);

    Assert.Equal("2026-12-31", svc.GetReportDateKey());

    clock.Advance(TimeSpan.FromHours(1));   // Reproduce el cambio de año en un instante
    Assert.Equal("2027-01-01", svc.GetReportDateKey());
}

Además de fijar la hora y avanzarla manualmente, FakeTimeProvider también permite sustituir la zona horaria local (SetLocalTimeZone), de modo que se puede reproducir en una máquina japonesa dentro de la CI un error del tipo «en un equipo configurado en Alemania la fecha aparece como la del día anterior». Sin embargo, esto solo funciona cuando el código bajo prueba pasa por provider.GetLocalNow() o provider.LocalTimeZone. Lo que se sustituye es la zona horaria que devuelve ese proveedor, no el TimeZoneInfo.Local de todo el proceso,7 así que no llega al código que llama directamente a DateTime.Now o ToLocalTime() (apartado 2.1). Es la consecuencia obvia de que «el código que no pasa por el reloj inyectado no se puede sustituir desde las pruebas». En proyectos antiguos de .NET Framework donde ni siquiera es sencillo añadir un paquete NuGet, una interfaz IClock propia con solo DateTimeOffset UtcNow { get; } produce el mismo efecto. Lo importante no es lo elaborada que sea la abstracción, sino tratar la «hora actual» como una dependencia que se puede inyectar.

Por experiencia, los momentos que como mínimo conviene incluir como casos de prueba son estos cinco: el cambio de año (fin y principio de año), el fin de mes (día 31, día 30, febrero), el 29 de febrero en años bisiestos, el día de cambio de horario de verano de la zona horaria en cuestión (primavera y otoño), y el entorno de la medianoche (el límite de la fecha de negocio). Todos ellos son el hábitat clásico de errores que solo se activan «ese día concreto», y con FakeTimeProvider se pueden probar todos en pocos milisegundos.

8. Lista de verificación y tabla de criterios

El tratamiento de fechas y horas es, al igual que los UUID, un elemento fundamental que suele dejarse de lado con la excusa de «parece que funciona» (la estructura del problema se parece mucho a la que describimos en «¿No colisionan los UUID?»). Fijar de antemano los puntos que se revisan en el diseño nuevo y en las revisiones elimina la dependencia de la persona que lo haga.

Lista de verificación para el diseño de código nuevo:

  • ¿Está unificada la obtención del momento en que ocurre un evento mediante DateTimeOffset.UtcNow / TimeProvider.GetUtcNow()?
  • ¿Están explícitos en la especificación la referencia (UTC o con desfase) y el formato (“o” / ISO 8601) para el almacenamiento y la comunicación?
  • ¿Están decididos el tipo de columna y la referencia en la base de datos (en SQL Server, datetime2 en UTC o datetimeoffset; incorporando Utc al nombre de la columna)?
  • ¿Se distingue la marca de tiempo de la fecha de negocio y se ha decidido la zona horaria con la que se corta la fecha?
  • ¿Están decididos el sistema de gestión de los identificadores de zona horaria (se recomienda el ID de IANA) y el tratamiento de errores para un ID inválido?
  • ¿Está decidida la política de DST para las ejecuciones periódicas (programación basada en UTC + idempotencia)?
  • ¿Se puede inyectar TimeProvider / IClock, y existen casos de prueba en los límites horarios?

Código que detectar con grep en la revisión:

Código que hace sospechar si aparece Qué puede ocurrir Cómo corregirlo
DateTime.Now Se desfasa al trasladar el servidor. No se puede probar UtcNow + conversión al mostrar. Inyectar TimeProvider
ToLocalTime() / ToUniversalTime() Interpretación implícita de Unspecified (capítulo 2) Fijar el Kind en el límite y convertir solo justo antes de mostrar
DateTime.Parse(s) (sin especificar styles) Depende de la referencia cultural y la zona horaria del entorno de ejecución ParseExact + InvariantCulture + RoundtripKind
Almacenar o comunicar con ToString("yyyy/MM/dd HH:mm") Se pierde la información de referencia Formato “o” + InvariantCulture
Uso directo de new DateTime(...) en comparaciones o almacenamiento Se cuela un valor Unspecified Aplicar SpecifyKind o pasar a DateTimeOffset
Columna datetime nueva en SQL Server Precisión, rango, proyección de futuro datetime2 / datetimeoffset10

La mayor parte de esta lista de verificación se puede decidir en el primer día si se trata de un desarrollo nuevo. En cambio, corregirlo después de que el sistema ya esté en producción se convierte en un trabajo de migración que consiste en estimar la referencia de los datos ya guardados (una especie de arqueología para averiguar con qué referencia entró cada dato según el periodo), y el costo cambia en un orden de magnitud.

9. Resumen

Los incidentes de fecha, hora y zona horaria presentan síntomas dispares —«se desfasa 9 horas», «la fecha aparece como la del día anterior», «el proceso por lotes se ejecuta dos veces»—, pero la causa es siempre la misma: un valor sin información de referencia cruza un límite. La solución también es siempre la misma. Obtener en UTC; guardar y comunicar en UTC o con desfase, más ISO 8601 (formato “o”); y usar hora local solo para mostrar. En los límites, dejar explícitos en la especificación el formato y la referencia, e incorporar la referencia al nombre de las columnas de la base de datos. Tratar las zonas horarias con TimeZoneInfo y el ID de IANA, y absorber las horas que no existen o son ambiguas del DST mediante la validación de entrada y ejecuciones periódicas idempotentes. Y, por último, inyectar TimeProvider para poder reproducir en pruebas tanto el cambio de año como el día de cambio de horario de verano. Si se llega hasta aquí, se pasa de ser quien recibe la llamada el día en que cambia el entorno a ser quien lo señala antes de que el cambio ocurra.

En nuestra empresa nos encargamos de investigar el origen de los desfases horarios asociados a traslados de servidores y migraciones a la nube, de revisar el diseño relacionado con el tratamiento de fechas y horas, y de apoyar la modernización necesaria para dar soporte a sucursales en el extranjero (tratamiento de zonas horarias y de DST). Si le resulta difícil decidir, podemos ayudarle también con la revisión y el plan de migración en los casos en que la referencia de los datos ya guardados se haya mezclado.

Artículos relacionados

Áreas de consultoría relacionadas

KomuraSoft LLC se encarga de la revisión del diseño del tratamiento de fecha, hora y zona horaria en aplicaciones empresariales, de la investigación del origen de los desfases de hora y fecha asociados a traslados de servidores y migraciones a la nube, y de la modernización del tratamiento de fecha y hora para dar soporte a sucursales en el extranjero o para código heredado.

Referencias

  1. Microsoft Learn, .NET globalization and ICU. Sobre que, en Windows, la resolución de los ID de zona horaria de IANA y las API de conversión mutua dependen de ICU, sobre que no se pueden usar en modo NLS ni en modo de globalización invariable, y sobre que en sistemas operativos sin ICU se puede recurrir a ICU local a la aplicación.  2 3 4

  2. Microsoft Learn, DateTime.Kind Property. Sobre que el valor por defecto de Kind es Unspecified, y sobre el efecto del valor de Kind en el resultado de ToLocalTime / ToUniversalTime (Unspecified se interpreta como UTC en ToLocalTime y como hora local en ToUniversalTime).  2 3

  3. Microsoft Learn, Choose between DateTime, DateOnly, DateTimeOffset, TimeSpan, TimeOnly, and TimeZoneInfo. Sobre por qué conviene considerar DateTimeOffset como el tipo de fecha y hora por defecto en el desarrollo de aplicaciones, sobre que DateTimeOffset solo lleva el desfase y no está asociado a una zona horaria, y sobre que DateOnly / TimeOnly no están disponibles en .NET Framework.  2 3 4 5

  4. Microsoft Learn, Standard date and time format strings. Sobre que el formato de ida y vuelta “o” es conforme a ISO 8601 y conserva en la cadena el Kind de DateTime y el desfase de DateTimeOffset, y sobre que se puede recuperar el valor original analizándolo de nuevo con DateTimeStyles.RoundtripKind.  2

  5. Microsoft Learn, What’s new in .NET 6. Sobre que en .NET 6, TimeZoneInfo.FindSystemTimeZoneById acepta tanto los ID de IANA como los de Windows y los convierte automáticamente, y sobre la incorporación de TryConvertIanaIdToWindowsId / TryConvertWindowsIdToIanaId.  2

  6. Microsoft Learn, TimeZoneInfo.IsInvalidTime(DateTime) Method. Sobre la definición y el método de determinación de la «hora que no existe» que se produce con el cambio al horario de verano, y sobre su contrapartida, IsAmbiguousTime (determinación de la hora ambigua).  2

  7. Microsoft Learn, What is TimeProvider?. Sobre que TimeProvider se incorpora de serie desde .NET 8, y que en .NET Framework 4.6.2+ / .NET Standard 2.0 se puede usar mediante el paquete Microsoft.Bcl.TimeProvider; sobre la abstracción de GetUtcNow / GetLocalNow / LocalTimeZone y la creación de temporizadores; y sobre que FakeTimeProvider, para pruebas, se ofrece en el paquete Microsoft.Extensions.TimeProvider.Testing. TimeProvider.LocalTimeZone es una propiedad que devuelve «la zona horaria local según la forma en que ese TimeProvider interpreta el tiempo», y se define de modo que la implementación por defecto devuelve TimeZoneInfo.Local. Es decir, lo que se sustituye con FakeTimeProvider es únicamente la conversión que pasa por ese proveedor; el TimeZoneInfo.Local de todo el proceso (el valor que consultan métodos como DateTime.ToLocalTime()) no cambia.  2 3 4

  8. Microsoft Learn, TimeZoneInfo.ConvertTime Method. Sobre que se exige coherencia entre DateTime.Kind y la zona horaria de origen de la conversión, produciéndose ArgumentException si no coinciden; sobre que una hora ambigua se interpreta como perteneciente a la hora estándar; y sobre que pasar una hora que no existe produce ArgumentException.  2

  9. Microsoft Learn, Globalization APIs use ICU libraries on Windows Server 2019. Sobre que, desde .NET 7, se pasó a usar las bibliotecas ICU incluso en sistemas como Windows Server 2019 que no las incluyen de serie, y sobre que antes de ese cambio era necesario desplegar manualmente ICU local a la aplicación. 

  10. Microsoft Learn, datetime (Transact-SQL). Sobre que en trabajos nuevos conviene evitar datetime y usar time / date / datetime2 / datetimeoffset, y sobre que datetime2 / datetimeoffset tienen mayor precisión y datetimeoffset admite el desfase de zona horaria.  2 3 4 5

  11. Microsoft Learn, Data types (Microsoft.Data.Sqlite). Sobre que SQLite solo tiene cuatro tipos primitivos, y sobre que Microsoft.Data.Sqlite guarda DateTime / DateTimeOffset como TEXT. 

  12. Microsoft Learn, Windows Time Service (W32Time). Sobre que el servicio de hora de Windows sincroniza mediante NTP la hora de los equipos de la red, sobre la jerarquía de sincronización en un dominio de Active Directory, y sobre que la autenticación Kerberos depende de la sincronización horaria. 

Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.

Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.

El artículo está directamente relacionado con los siguientes servicios.

Preguntas frecuentes

Preguntas habituales en las consultas sobre el tema del artículo.

¿Por qué la hora se desplaza 9 horas después de trasladar el servidor?
La causa raíz es que un valor que no lleva la información de «respecto a qué referencia está esta hora» cruza límites como la base de datos, una API o un archivo. En .NET, el DateTime tiene por defecto Kind = Unspecified, y ToLocalTime() lo interpreta silenciosamente como UTC (sumando 9 horas), mientras que ToUniversalTime() lo interpreta como hora local (restando 9 horas). Este comportamiento depende de la configuración de zona horaria de la máquina donde se ejecuta el código, por lo que en un equipo de desarrollo configurado en hora de Japón el problema no se manifiesta, y se hace evidente de golpe el día en que se traslada la aplicación a una VM en la nube configurada en UTC.
¿DateTime o DateTimeOffset: cuál debería usar?
Para código nuevo, la opción por defecto es DateTimeOffset. Como siempre lleva el desfase respecto a UTC, el valor por sí solo determina de forma inequívoca a qué instante del mundo corresponde, de modo que los errores por interpretación implícita del Kind dejan de ser estructuralmente posibles. La guía oficial también recomienda explícitamente considerarlo como el tipo de fecha y hora por defecto para el desarrollo de aplicaciones. Sin embargo, el desfase no es lo mismo que una zona horaria, así que cuando se necesitan las reglas de ajuste del horario de verano hay que combinarlo con TimeZoneInfo. Cuando ya existe una gran cantidad de código basado en DateTime, una solución intermedia realista es unificar el uso interno y el almacenamiento con DateTime de Kind=Utc, y reservar DateTimeOffset solo para los límites.
¿Una aplicación de uso exclusivamente nacional en Japón también necesita medidas para el horario de verano (DST)?
Es necesario si se cumple alguna de estas condiciones: la aplicación se ejecuta en equipos de sucursales en el extranjero o de empleados que viajan al exterior, se integra con SaaS o API web de proveedores extranjeros, o hay procesos por lotes que se ejecutan en servidores de regiones extranjeras. En las zonas horarias que tienen horario de verano, el día del cambio se generan «horas que no existen» y «horas ambiguas», y las API de conversión de TimeZoneInfo lanzan ArgumentException si se les pasa una hora que no existe. Un trabajo periódico que se ejecuta a las 02:30 hora local puede saltarse el día en que empieza el horario de verano o ejecutarse dos veces el día en que termina, por lo que la solución consiste en programar los trabajos en referencia a UTC y diseñar el procesamiento para que sea idempotente.
¿Cómo se deberían escribir las pruebas del tratamiento de fecha y hora?
Deje de escribir DateTime.Now directamente en el código e inyecte TimeProvider, el estándar de .NET 8, para poder sustituir la hora actual. Incluso en .NET Framework 4.6.2 o posterior se puede usar el mismo tipo mediante el paquete Microsoft.Bcl.TimeProvider. En las pruebas, FakeTimeProvider permite fijar y avanzar la hora, además de sustituir la zona horaria local, de modo que los errores relacionados con el cambio de año o con el día del cambio de horario de verano se pueden reproducir en la integración continua (CI). Como mínimo conviene probar estos cinco momentos: el cambio de año, el fin de mes, el 29 de febrero en años bisiestos, el día del cambio de horario de verano y el entorno de la medianoche.

Perfil del autor

Página de presentación del autor del artículo.

Go Komura

Representante de KomuraSoft LLC

Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.

Volver al blog