Fechas y horas en C#: DateOnly, DateTime, zonas y formatos

Las fechas son de lo más traicionero en cualquier lenguaje: zonas horarias, cambios de hora, formatos que cambian con el idioma. C# tiene tipos distintos para cada caso, y elegir bien cuál usar evita la mayoría de los errores. Todas las fechas de esta página son fijas, para que veas exactamente las mismas salidas.

Qué tipo usar

.NET tiene varios tipos para el tiempo, y elegir el adecuado ahorra la mitad de los problemas:

TipoGuardaPara
DateOnlyuna fecha sin horacumpleaños, vencimientos, festivos
TimeOnlyuna hora sin fechahorarios de apertura, alarmas
DateTimefecha y hora, sin zona horaria claracuentas locales y código antiguo
DateTimeOffsetfecha, hora y la diferencia con UTCun instante exacto: registros, eventos, API
TimeSpanuna duraciónel resultado de restar dos fechas
var cita = new DateTime(2026, 10, 25, 9, 30, 0);
var dia = new DateOnly(2026, 10, 25);
var hora = new TimeOnly(9, 30);
var conZona = new DateTimeOffset(2026, 10, 25, 9, 30, 0, TimeSpan.FromHours(-6));

Console.WriteLine(cita.ToString("yyyy-MM-dd HH:mm"));
Console.WriteLine(dia.ToString("yyyy-MM-dd"));
Console.WriteLine(hora.ToString("HH:mm"));
Console.WriteLine(conZona.ToString("yyyy-MM-dd HH:mm zzz"));
Console.WriteLine(conZona.UtcDateTime.ToString("yyyy-MM-dd HH:mm"));
salida2026-10-25 09:30
2026-10-25
09:30
2026-10-25 09:30 -06:00
2026-10-25 15:30

DateTimeOffset sabe que las 9:30 en una zona UTC-6 son las 15:30 en UTC. Un DateTime no lo sabe: su propiedad Kind dice si es local, UTC o «sin especificar», y casi siempre es lo último. Para guardar cuándo pasó algo, usa DateTimeOffset; para la fecha de un calendario, DateOnly.

Mostrar fechas

using System.Globalization;

var fecha = new DateTime(2026, 10, 25, 9, 5, 0);
var es = new CultureInfo("es-ES");
var us = new CultureInfo("en-US");

Console.WriteLine(fecha.ToString("dddd, d 'de' MMMM 'de' yyyy", es));
Console.WriteLine(fecha.ToString("d", es));
Console.WriteLine(fecha.ToString("d", us));
Console.WriteLine(fecha.ToString("o", CultureInfo.InvariantCulture));
Console.WriteLine(fecha.ToString("yyyy-mm-dd"));
salidadomingo, 25 de octubre de 2026
25/10/2026
10/25/2026
2026-10-25T09:05:00.0000000
2026-05-25

Los nombres de días y meses, y el orden día/mes, dependen de la cultura. El formato o es ISO 8601, el que hay que usar en archivos y API. La última línea es una trampa clásica: mm en minúscula son minutos, no el mes, y por eso sale 2026-05-25. El mes es MM.

Sumar, restar y contar

var inicio = new DateOnly(2026, 1, 31);
Console.WriteLine(inicio.AddMonths(1).ToString("yyyy-MM-dd"));
Console.WriteLine(inicio.AddDays(30).DayOfWeek);

var entrega = new DateTime(2026, 12, 24, 18, 0, 0);
var ahora = new DateTime(2026, 10, 25, 9, 30, 0);
TimeSpan falta = entrega - ahora;
Console.WriteLine($"Faltan {falta.Days} días y {falta.Hours} horas");
Console.WriteLine($"En total, {falta.TotalHours:F1} horas");

int diasLaborables = 0;
for (var d = new DateOnly(2026, 11, 1); d <= new DateOnly(2026, 11, 30); d = d.AddDays(1))
{
    if (d.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday)) diasLaborables++;
}
Console.WriteLine($"Noviembre de 2026 tiene {diasLaborables} días laborables");
salida2026-02-28
Monday
Faltan 60 días y 8 horas
En total, 1448.5 horas
Noviembre de 2026 tiene 21 días laborables

DayOfWeek es un enum, y por eso se escribe en inglés (Monday); para el nombre en español, fecha.ToString("dddd", new CultureInfo("es-ES")). Sumar un mes al 31 de enero da el último día de febrero, no un 31 de febrero inexistente. Restar dos fechas da un TimeSpan: Days y Hours son las partes, y TotalHours, la duración entera en horas, con decimales.

Leer fechas de un texto

using System.Globalization;

var fecha = DateTime.ParseExact("25/10/2026 09:30", "dd/MM/yyyy HH:mm", CultureInfo.InvariantCulture);
Console.WriteLine(fecha.ToString("yyyy-MM-dd HH:mm"));

string[] entradas = ["2026-02-29", "2026-03-01", "ayer"];
foreach (var e in entradas)
{
    bool ok = DateOnly.TryParseExact(e, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var d);
    Console.WriteLine(ok ? $"{e}: {d.DayOfWeek}" : $"{e}: no es una fecha válida");
}
salida2026-10-25 09:30
2026-02-29: no es una fecha válida
2026-03-01: Sunday
ayer: no es una fecha válida

ParseExact y TryParseExact exigen el formato que les digas, sin adivinar. Es lo que quieres con datos de un archivo o un formulario: DateTime.Parse("03/04/2026") da el 3 de abril o el 4 de marzo según el idioma del sistema. 2026 no es bisiesto, así que el 29 de febrero se rechaza.

Zonas horarias

var instante = new DateTimeOffset(2026, 10, 25, 15, 0, 0, TimeSpan.Zero);

foreach (var id in new[] { "America/Mexico_City", "America/Bogota", "Europe/Madrid", "America/Argentina/Buenos_Aires" })
{
    var zona = TimeZoneInfo.FindSystemTimeZoneById(id);
    var local = TimeZoneInfo.ConvertTime(instante, zona);
    Console.WriteLine($"{id,-32} {local:yyyy-MM-dd HH:mm zzz}");
}

// La noche del cambio de hora en Madrid: el 25 de octubre de 2026 a las 3:00 vuelven a ser las 2:00
var madrid = TimeZoneInfo.FindSystemTimeZoneById("Europe/Madrid");
Console.WriteLine(madrid.IsAmbiguousTime(new DateTime(2026, 10, 25, 2, 30, 0)));
salidaAmerica/Mexico_City              2026-10-25 09:00 -06:00
America/Bogota                   2026-10-25 10:00 -05:00
Europe/Madrid                    2026-10-25 16:00 +01:00
America/Argentina/Buenos_Aires   2026-10-25 12:00 -03:00
True

Desde .NET 6 los identificadores de la IANA (Europe/Madrid) funcionan también en Windows, que usa los suyos (Romance Standard Time). ConvertTime aplica el horario de verano de cada sitio: el mismo instante es otra hora en cada ciudad. Y el 25 de octubre de 2026, día del cambio de hora en España, las 2:30 de la madrugada ocurren dos veces: por eso son una hora ambigua.

DateTime.Now complica las pruebas. Un programa que llama a DateTime.Now da un resultado distinto cada vez y no se puede probar el día de Año Nuevo sin esperar a que llegue. Desde .NET 8, recibe un TimeProvider: en producción pasas TimeProvider.System, y en las pruebas, un reloj falso que puedes adelantar.
using Microsoft.Extensions.Time.Testing;

var reloj = new FakeTimeProvider(new DateTimeOffset(2026, 12, 31, 23, 59, 0, TimeSpan.Zero));
Console.WriteLine(Saludo(reloj));
reloj.Advance(TimeSpan.FromMinutes(2));
Console.WriteLine(Saludo(reloj));

static string Saludo(TimeProvider reloj) =>
    reloj.GetUtcNow().Month == 1 && reloj.GetUtcNow().Day == 1 ? "¡Feliz año nuevo!" : "Todavía no";
salidaTodavía no
¡Feliz año nuevo!

Todo el código de esta página se compiló con el .NET SDK 10.0.403 (C# 14) y se ejecutó con .NET 10.0.14 en un contenedor limpio antes de publicarla; las salidas y los mensajes del compilador están copiados de esa ejecución.