Mert Özen Further With Every Line
Backend Everyday .NET APIs with C# — #06

Date and Time in Depth: TimeZoneInfo, IANA/Windows Ids, ParseExact, ISO 8601 and Date Arithmetic

Mert Özen Nov 15, 2026 20 min 11 views
Date and Time in Depth: TimeZoneInfo, IANA/Windows Ids, ParseExact, ISO 8601 and Date Arithmetic

Most date and time bugs come from three places: the wrong time zone id, a DateTime parsed with an unclear Kind, and arithmetic that ignores month ends or daylight saving time. Using Bookstore's 5-business-day delivery rule and its Frankfurt warehouse, we show how TimeZoneInfo handles Windows and IANA ids, ParseExact and DateTimeStyles, ISO 8601 round-trips, DateOnly/TimeOnly in JSON and testable date logic with TimeProvider, all with real output.

Version note: The code in this post was built and run with .NET 10 and Microsoft.Extensions.TimeProvider.Testing 11.0.0; the output was captured in a Linux container with tzdata 2026e.

Guess First

Bookstore, the online bookstore used as the shared example across this site's series, bills its corporate customers with invoices due "one month later". For an invoice dated 31 January 2027, what do the following four lines print? The last line uses a Windows-specific time zone id, and the code runs on Linux:

DateTime approved = new(2027, 1, 31);
Console.WriteLine(approved.AddMonths(1).ToString("yyyy-MM-dd", Invariant));
Console.WriteLine(approved.AddMonths(1).AddMonths(1).ToString("yyyy-MM-dd", Invariant));
Console.WriteLine(approved.AddMonths(2).ToString("yyyy-MM-dd", Invariant));

TimeZoneInfo warehouse = TimeZoneInfo.FindSystemTimeZoneById("W. Europe Standard Time");
Console.WriteLine(warehouse.Id);
2027-02-28
2027-03-28
2027-03-31
W. Europe Standard Time

AddMonths(1) cannot go from 31 January to 31 February, so it lands on the last day of the month, 28 February. When you add another month, the original "31" is already gone and the result is 28 March; adding two months in one step gives 31 March. Two pieces of code doing the same job in two different ways compute due dates three days apart. The last line surprises most people too: the Windows id did not throw on Linux, and the Id property even returned the name we asked for.

The C# Fundamentals series covered the types themselves in "Dates and Times in C#", and the Solution Patterns series covered how to design time in the database in "Time and Time Zones". This post looks at the APIs you write every day: TimeZoneInfo and the id difference across platforms, ParseExact and DateTimeStyles, lossless round-trips with ISO 8601, month ends, ages and TimeSpan formatting, and finally making a delivery-date class testable with TimeProvider. You can run the examples yourself:

dotnet run --project samples/Bookstore.Bcl.Samples -- date-zones --culture en-US

TimeZoneInfo: Windows and IANA Ids

Time zone rules come from the operating system. Windows keeps them in the registry under names such as "Turkey Standard Time" and "W. Europe Standard Time". Linux and macOS use IANA names: "Europe/Istanbul", "Europe/Berlin". For years this was the first problem of code written on Windows and run in a Linux container. Since .NET 6 both kinds of id are accepted on both platforms; the mapping table needed for the conversion comes from ICU. I looked up four ids in a Linux container:

foreach (string id in new[] { "Turkey Standard Time", "Europe/Istanbul", "W. Europe Standard Time", "Europe/Berlin" })
{
    TimeZoneInfo zone = TimeZoneInfo.FindSystemTimeZoneById(id);
    Console.WriteLine($"{id,-24} -> Id={zone.Id,-24} HasIanaId={zone.HasIanaId,-5} BaseUtcOffset={zone.BaseUtcOffset}");
}

TimeZoneInfo.TryConvertIanaIdToWindowsId("Europe/Istanbul", out string? windowsId);
Console.WriteLine($"Europe/Istanbul -> Windows: {windowsId}");
TimeZoneInfo.TryConvertWindowsIdToIanaId("W. Europe Standard Time", out string? ianaId);
Console.WriteLine($"W. Europe Standard Time -> IANA: {ianaId}");
TimeZoneInfo.TryConvertWindowsIdToIanaId("W. Europe Standard Time", "NL", out string? ianaNl);
Console.WriteLine($"W. Europe Standard Time + NL -> IANA: {ianaNl}");
bool known = TimeZoneInfo.TryConvertIanaIdToWindowsId("Europe/Frankfurt", out string? missing);
Console.WriteLine($"Europe/Frankfurt known: {known} ({missing ?? "null"})");

try
{
    TimeZoneInfo.FindSystemTimeZoneById("Europe/Frankfurt");
}
catch (TimeZoneNotFoundException ex)
{
    Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}
Turkey Standard Time     -> Id=Turkey Standard Time     HasIanaId=False BaseUtcOffset=03:00:00
Europe/Istanbul          -> Id=Europe/Istanbul          HasIanaId=True  BaseUtcOffset=03:00:00
W. Europe Standard Time  -> Id=W. Europe Standard Time  HasIanaId=False BaseUtcOffset=01:00:00
Europe/Berlin            -> Id=Europe/Berlin            HasIanaId=True  BaseUtcOffset=01:00:00
Europe/Istanbul -> Windows: Turkey Standard Time
W. Europe Standard Time -> IANA: Europe/Berlin
W. Europe Standard Time + NL -> IANA: Europe/Amsterdam
Europe/Frankfurt known: False (null)
TimeZoneNotFoundException: The time zone ID 'Europe/Frankfurt' was not found on the local computer.

Three things stand out. First, Id returns the name you passed in, and HasIanaId tells you which system that name comes from. This matters if you store the id or send it to another service: a zone you found on Linux with "Turkey Standard Time" does not have an IANA name as its Id. Second, mapping from Windows to IANA is not one-to-one. "W. Europe Standard Time" corresponds to many IANA zones such as Berlin, Amsterdam, Rome and Vienna; without a region code you get the mapping table's default (Berlin here), and with "NL" you get Amsterdam. Cities that share the same rules today applied different rules in the past and may diverge in the future. Third, you do not guess ids: Frankfurt is one of Germany's best-known cities, but the tz database has no "Europe/Frankfurt" zone; Germany is represented by "Europe/Berlin".

On Windows, FindSystemTimeZoneById first looks in the registry; IANA ids have been accepted through ICU since .NET 6. According to the API page on Microsoft Learn, this requires NLS mode and globalization invariant mode to be off. The InvariantGlobalization=true setting we saw in the third post of this series takes ICU out of the picture, so the id conversion stops working too. My advice: always store the IANA id in code, configuration and the database. If a Windows API or an old system is the only place that needs a Windows id, convert at the boundary with TryConvertIanaIdToWindowsId.

Converting Between Zones and Daylight Saving Time

Bookstore's European orders ship from a warehouse in Frankfurt. Corporate orders are approved in Istanbul time, while the warehouse wants to see its own local time on screen. Let us convert two approvals made at the same clock time to warehouse time; in between, daylight saving time ends in Europe on 25 October 2026:

TimeZoneInfo istanbul = TimeZoneInfo.FindSystemTimeZoneById("Europe/Istanbul");
TimeZoneInfo warehouse = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");

// Corporate orders are approved in Istanbul time; the warehouse is in Frankfurt.
DateTimeOffset[] approvals =
[
    new(2026, 10, 23, 17, 30, 0, TimeSpan.FromHours(3)),
    new(2026, 10, 26, 17, 30, 0, TimeSpan.FromHours(3)),
];

foreach (DateTimeOffset approvedAt in approvals)
{
    DateTimeOffset atWarehouse = TimeZoneInfo.ConvertTime(approvedAt, warehouse);
    Console.WriteLine($"Istanbul {approvedAt:yyyy-MM-dd HH:mm zzz} -> warehouse {atWarehouse:yyyy-MM-dd HH:mm zzz}  (UTC {approvedAt.UtcDateTime:HH:mm})");
}

DateTime pickupLocal = new(2026, 11, 16, 15, 0, 0, DateTimeKind.Unspecified);
DateTime pickupUtc = TimeZoneInfo.ConvertTimeToUtc(pickupLocal, warehouse);
DateTime pickupIstanbul = TimeZoneInfo.ConvertTime(pickupLocal, warehouse, istanbul);
Console.WriteLine($"Courier pickup 15:00 Berlin = {pickupUtc:HH:mm} UTC = {pickupIstanbul:HH:mm} Istanbul");

DateTimeOffset byId = TimeZoneInfo.ConvertTimeBySystemTimeZoneId(approvals[0], "W. Europe Standard Time");
Console.WriteLine($"ConvertTimeBySystemTimeZoneId: {byId:yyyy-MM-dd HH:mm zzz}");
Istanbul 2026-10-23 17:30 +03:00 -> warehouse 2026-10-23 16:30 +02:00  (UTC 14:30)
Istanbul 2026-10-26 17:30 +03:00 -> warehouse 2026-10-26 15:30 +01:00  (UTC 14:30)
Courier pickup 15:00 Berlin = 14:00 UTC = 17:00 Istanbul
ConvertTimeBySystemTimeZoneId: 2026-10-23 16:30 +02:00

Türkiye has not used daylight saving time since 2016 and stays at UTC+03:00 all year. Germany is at +02:00 until the last Sunday of October and at +01:00 after that. So within one week, the difference between Istanbul and the warehouse went from one hour to two. If you hard-code a fixed difference like "the warehouse is one hour behind us", you get wrong results for five months of the year. Do not memorize the rule; pass the zone and leave the conversion to TimeZoneInfo.

The choice of overload matters as well. ConvertTime(DateTimeOffset, TimeZoneInfo) takes a specific instant and returns it with the offset of the target zone; the instant itself does not change, which makes this the safest path. The overloads that take a DateTime look at its Kind property: they treat an Unspecified value as local time in the source zone and a Local value as the machine's zone. "The courier comes at 15:00 Berlin time" is not an instant but a wall-clock value; building it as Unspecified and passing the source zone explicitly is the right move.

Times That Do Not Exist and Times That Happen Twice

On the night daylight saving time starts, one hour is skipped; on the night it ends, one hour happens twice. If you schedule a job for the warehouse such as "inventory count every day at 02:30", these two nights cause trouble:

TimeZoneInfo warehouse = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
DateTime springGap = new(2026, 3, 29, 2, 30, 0);
DateTime autumnOverlap = new(2026, 10, 25, 2, 30, 0);

Console.WriteLine($"{springGap:yyyy-MM-dd HH:mm} invalid: {warehouse.IsInvalidTime(springGap)}");
Console.WriteLine($"{autumnOverlap:yyyy-MM-dd HH:mm} ambiguous: {warehouse.IsAmbiguousTime(autumnOverlap)}, offsets: {string.Join(" / ", warehouse.GetAmbiguousTimeOffsets(autumnOverlap))}");

try
{
    TimeZoneInfo.ConvertTimeToUtc(springGap, warehouse);
}
catch (ArgumentException ex)
{
    Console.WriteLine($"{ex.GetType().Name}: {ex.Message.Split(" (Parameter")[0]}");
}

DateTime utc = TimeZoneInfo.ConvertTimeToUtc(autumnOverlap, warehouse);
Console.WriteLine($"ConvertTimeToUtc(ambiguous) = {utc:HH:mm} UTC (standard offset chosen)");

DateTimeOffset start = new(2026, 10, 24, 12, 0, 0, TimeSpan.FromHours(2));
DateTimeOffset nextDayWall = new(2026, 10, 25, 12, 0, 0, TimeSpan.FromHours(1));
Console.WriteLine($"Saturday 12:00 -> Sunday 12:00 Berlin = {(nextDayWall - start).TotalHours} hours");
2026-03-29 02:30 invalid: True
2026-10-25 02:30 ambiguous: True, offsets: 01:00:00 / 02:00:00
ArgumentException: The supplied DateTime represents an invalid time.  For example, when the clock is adjusted forward, any time in the period that is skipped is invalid.
ConvertTimeToUtc(ambiguous) = 01:30 UTC (standard offset chosen)
Saturday 12:00 -> Sunday 12:00 Berlin = 25 hours

29 March 02:30 never happens in Berlin, and the conversion throws. 25 October 02:30 happens twice; ConvertTimeToUtc picks standard time, the second occurrence, without asking. Which one you want is a business rule: the inventory count must not run twice, and a payment reminder must never be skipped. That is why scheduled work should check the situation itself with IsInvalidTime and IsAmbiguousTime, or be scheduled in UTC. The last line shows another common mistake: that weekend in Berlin, "the same time on the next day" is 25 hours away. "24 hours later" and "the same time tomorrow" are different things.

ParseExact and TryParseExact

Corporate customers send the delivery date in an Excel template, and the cells arrive as "14.11.2026 09:30". Stating the formats you accept is safer than letting DateTime.Parse guess based on the culture:

string[] formats = ["dd.MM.yyyy HH:mm", "dd.MM.yyyy", "yyyy-MM-dd'T'HH:mm"];
string[] inputs = ["14.11.2026 09:30", "14.11.2026", "2026-11-14T09:30", "14/11/2026", "4.11.2026"];

foreach (string input in inputs)
{
    bool ok = DateTime.TryParseExact(input, formats, Invariant, DateTimeStyles.None, out DateTime value);
    Console.WriteLine($"{input,-18} -> {(ok ? value.ToString("yyyy-MM-dd HH:mm", Invariant) : "rejected")}");
}

try
{
    DateTime.ParseExact("2026-11-14 9:30", "yyyy-MM-dd HH:mm", Invariant);
}
catch (FormatException ex)
{
    Console.WriteLine($"FormatException: {ex.Message}");
}

DateTime single = DateTime.ParseExact("2026-11-14 9:30", "yyyy-MM-dd H:mm", Invariant);
Console.WriteLine($"'H:mm' accepts 9:30 -> {single:HH:mm}");
14.11.2026 09:30   -> 2026-11-14 09:30
14.11.2026         -> 2026-11-14 00:00
2026-11-14T09:30   -> 2026-11-14 09:30
14/11/2026         -> rejected
4.11.2026          -> rejected
FormatException: String '2026-11-14 9:30' was not recognized as a valid DateTime.
'H:mm' accepts 9:30 -> 09:30

The format array is tried in order, and the first match wins. ParseExact really is exact: dd requires two digits, so "4.11.2026" was rejected; to accept a single digit too, you add the d.M.yyyy format to the list. The same difference exists between HH and H. Format strings follow the same rules as in the second post of this series; the MM/mm mix-up we saw there is sneakier on the parsing side, because a wrong value may be accepted without an exception. Passing InvariantCulture as the format provider is a deliberate choice too: parse every piece of text that does not come from the user interface independently of the culture. For dates typed in by users, return a clear message like "The date must be in dd.mm.yyyy format" based on the result of TryParseExact; catching and swallowing the exception is both slow and pointless.

DateTimeStyles and Kind

The Kind of the value you parse affects every later conversion. The output below comes from a Linux container running with TZ=Europe/Istanbul; on your machine the times change with your local zone, and what matters is the Kind column:

// This demo ran in a container with TZ=Europe/Istanbul.
string utcText = "2026-11-14T09:30:00Z";
string noOffset = "2026-11-14T09:30:00";

DateTime plain = DateTime.Parse(utcText, Invariant);
DateTime adjusted = DateTime.Parse(utcText, Invariant, DateTimeStyles.AdjustToUniversal);
DateTime roundtrip = DateTime.Parse(utcText, Invariant, DateTimeStyles.RoundtripKind);
DateTime unspecified = DateTime.Parse(noOffset, Invariant, DateTimeStyles.RoundtripKind);
DateTime assumed = DateTime.Parse(noOffset, Invariant, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);

Console.WriteLine($"Parse(Z)                   -> {plain:HH:mm} Kind={plain.Kind}");
Console.WriteLine($"Parse(Z, AdjustToUniversal)-> {adjusted:HH:mm} Kind={adjusted.Kind}");
Console.WriteLine($"Parse(Z, RoundtripKind)    -> {roundtrip:HH:mm} Kind={roundtrip.Kind}");
Console.WriteLine($"Parse(no offset)           -> {unspecified:HH:mm} Kind={unspecified.Kind}");
Console.WriteLine($"  .ToUniversalTime()       -> {unspecified.ToUniversalTime():HH:mm} (treated as local)");
Console.WriteLine($"Parse(no offset, AssumeUniversal|AdjustToUniversal) -> {assumed:HH:mm} Kind={assumed.Kind}");

DateTimeOffset offset = DateTimeOffset.Parse(noOffset, Invariant);
Console.WriteLine($"DateTimeOffset.Parse(no offset) -> {offset:yyyy-MM-dd HH:mm zzz}");
Parse(Z)                   -> 12:30 Kind=Local
Parse(Z, AdjustToUniversal)-> 09:30 Kind=Utc
Parse(Z, RoundtripKind)    -> 09:30 Kind=Utc
Parse(no offset)           -> 09:30 Kind=Unspecified
  .ToUniversalTime()       -> 06:30 (treated as local)
Parse(no offset, AssumeUniversal|AdjustToUniversal) -> 09:30 Kind=Utc
DateTimeOffset.Parse(no offset) -> 2026-11-14 09:30 +03:00

The first line is surprising but documented: when you parse a value ending in Z with the default settings, DateTime converts it to the machine's local time and returns Kind=Local. On a server in Istanbul, 09:30 UTC becomes 12:30. AdjustToUniversal or RoundtripKind prevents this. Text without an offset becomes Unspecified, and the trap here is big: ToUniversalTime() treats an Unspecified value as local time, so a value that was actually UTC shifts back three hours. The same code gives three different results on the developer's machine, in a container running in UTC and on a server in Istanbul. If you know the text is UTC, say so explicitly with AssumeUniversal | AdjustToUniversal. DateTimeOffset.Parse also adds the local zone's offset to text without one, so it depends on the machine as well.

Lossless Round-Trips with ISO 8601

When you write an instant to an API, a queue or a log, the goal is that the reader gets back the same instant and the same offset. The "o" (round-trip) format exists for this:

DateTimeOffset approvedAt = new(2026, 11, 14, 9, 30, 15, 250, TimeSpan.FromHours(3));
string wire = approvedAt.ToString("o", Invariant);
DateTimeOffset back = DateTimeOffset.ParseExact(wire, "o", Invariant);
Console.WriteLine($"DateTimeOffset \"o\" : {wire}");
Console.WriteLine($"Round-trip equal   : {back == approvedAt}, EqualsExact: {back.EqualsExact(approvedAt)}");
Console.WriteLine($"Same instant in UTC: {approvedAt.ToUniversalTime():o}");
Console.WriteLine($"== UTC version     : {approvedAt == approvedAt.ToUniversalTime()}, EqualsExact: {approvedAt.EqualsExact(approvedAt.ToUniversalTime())}");

DateTime utc = new(2026, 11, 14, 6, 30, 15, DateTimeKind.Utc);
DateTime unspecified = new(2026, 11, 14, 6, 30, 15);
Console.WriteLine($"DateTime Utc \"o\"   : {utc:o}");
Console.WriteLine($"Unspecified \"o\"    : {unspecified:o}");
Console.WriteLine($"\"s\" / \"u\"          : {utc:s} / {utc:u}");
Console.WriteLine($"JSON DateTimeOffset: {JsonSerializer.Serialize(approvedAt)}");
Console.WriteLine($"JSON Unspecified   : {JsonSerializer.Serialize(unspecified)}");
DateTimeOffset "o" : 2026-11-14T09:30:15.2500000+03:00
Round-trip equal   : True, EqualsExact: True
Same instant in UTC: 2026-11-14T06:30:15.2500000+00:00
== UTC version     : True, EqualsExact: False
DateTime Utc "o"   : 2026-11-14T06:30:15.0000000Z
Unspecified "o"    : 2026-11-14T06:30:15.0000000
"s" / "u"          : 2026-11-14T06:30:15 / 2026-11-14 06:30:15Z
JSON DateTimeOffset: "2026-11-14T09:30:15.25+03:00"
JSON Unspecified   : "2026-11-14T06:30:15"

The "o" format keeps the offset and the precision of the 100-nanosecond tick; the value read back with ParseExact(..., "o", ...) passes the EqualsExact test too. The == operator compares only the instant: 09:30 in Istanbul equals 06:30 in UTC. EqualsExact also requires the offset to match. That is why carrying the offset is valuable if you want to report which zone an order was placed in.

The real danger is in the last lines. An Unspecified DateTime is written without an offset, both with "o" and with System.Text.Json. When the other side reads that text, it cannot know the zone and applies its own rule; the ToUniversalTime() shift we saw in the previous section starts exactly like this. The "s" format carries no offset either; "u" does not convert to UTC, it only appends a Z, so use it only with a value that really is UTC. The rule is simple: every instant that crosses a system boundary should be either a DateTimeOffset or a DateTime with Kind=Utc.

DateOnly and TimeOnly

A delivery day is not an instant but a day on the calendar. DateOnly and TimeOnly behave like this when parsing and in JSON:

DateOnly delivery = new(2026, 11, 20);
TimeOnly slot = new(14, 30);
CultureInfo tr = CultureInfo.GetCultureInfo("tr-TR");

Console.WriteLine($"ToString(tr-TR)     : {delivery.ToString(tr)} {slot.ToString(tr)}");
Console.WriteLine($"ToString(\"O\")       : {delivery:O} {slot:O}");
Console.WriteLine($"ParseExact dd.MM.yyyy: {DateOnly.ParseExact("20.11.2026", "dd.MM.yyyy", Invariant):O}");
Console.WriteLine($"TryParse with time  : {DateOnly.TryParse("2026-11-20 14:30", Invariant, out _)}");

string json = JsonSerializer.Serialize(new DeliverySlot(delivery, slot));
Console.WriteLine($"JSON                : {json}");
Console.WriteLine($"Back                : {JsonSerializer.Deserialize<DeliverySlot>(json)}");

try
{
    JsonSerializer.Deserialize<DeliverySlot>("""{"Day":"20.11.2026","At":"14:30"}""");
}
catch (JsonException ex)
{
    Console.WriteLine($"JsonException       : {ex.Message.Split(" Path:")[0]}");
}

DateOnly fromDateTime = DateOnly.FromDateTime(new DateTime(2026, 11, 20, 23, 59, 0));
Console.WriteLine($"FromDateTime        : {fromDateTime:O}, ToDateTime: {delivery.ToDateTime(slot):O}");
Console.WriteLine($"ISO week (.NET 10)  : {ISOWeek.GetWeekOfYear(delivery)}");
ToString(tr-TR)     : 20.11.2026 14:30
ToString("O")       : 2026-11-20 14:30:00.0000000
ParseExact dd.MM.yyyy: 2026-11-20
TryParse with time  : False
JSON                : {"Day":"2026-11-20","At":"14:30:00"}
Back                : DeliverySlot { Day = 11/20/2026, At = 2:30 PM }
JsonException       : The JSON value could not be converted to Bookstore.Bcl.Samples.Dates.DateDemos+DeliverySlot.
FromDateTime        : 2026-11-20, ToDateTime: 2026-11-20T14:30:00.0000000
ISO week (.NET 10)  : 47

System.Text.Json writes DateOnly as yyyy-MM-dd and TimeOnly as HH:mm:ss, and reads only the ISO form; it does not accept JSON containing "20.11.2026". So convert locally formatted dates from Excel with ParseExact before they go into JSON. DateOnly.TryParse also rejects text that contains a time; that is good behavior, because silently dropping the time loses information. FromDateTime, on the other hand, cuts off the time without asking: if you convert a 23:59 UTC value to DateOnly without first converting it to Istanbul time, you land on the wrong day. With .NET 10, the ISOWeek class gained DateOnly overloads; for weekly warehouse reports you no longer need to build a separate DateTime to get the week number.

Date Arithmetic: Month Ends, Ages and TimeSpan

Back to the due-date question from the start; let us put three approaches side by side. BusinessCalendar.EndOfMonth is a small helper in the Bookstore library: it finds the last day of the month with DateTime.DaysInMonth.

public static DateOnly EndOfMonth(DateOnly day) =>
    new(day.Year, day.Month, DateTime.DaysInMonth(day.Year, day.Month));
DateTime invoiceDay = new(2027, 1, 31);
for (int i = 1; i <= 3; i++)
    Console.Write($"AddMonths({i}) = {invoiceDay.AddMonths(i):yyyy-MM-dd}   ");
Console.WriteLine();

DateTime chained = invoiceDay;
for (int i = 1; i <= 3; i++)
{
    chained = chained.AddMonths(1);
    Console.Write($"chained {i} = {chained:yyyy-MM-dd}   ");
}
Console.WriteLine();

DateOnly start = new(2027, 1, 31);
for (int i = 1; i <= 3; i++)
    Console.Write($"EndOfMonth(+{i}) = {BusinessCalendar.EndOfMonth(start.AddMonths(i)):yyyy-MM-dd}   ");
Console.WriteLine();

Console.WriteLine($"2028-02-29 AddYears(1) = {new DateOnly(2028, 2, 29).AddYears(1):yyyy-MM-dd}");
Console.WriteLine($"Days between 2026-11-14 and 2027-01-01 = {new DateOnly(2027, 1, 1).DayNumber - new DateOnly(2026, 11, 14).DayNumber}");
AddMonths(1) = 2027-02-28   AddMonths(2) = 2027-03-31   AddMonths(3) = 2027-04-30   
chained 1 = 2027-02-28   chained 2 = 2027-03-28   chained 3 = 2027-04-28   
EndOfMonth(+1) = 2027-02-28   EndOfMonth(+2) = 2027-03-31   EndOfMonth(+3) = 2027-04-30   
2028-02-29 AddYears(1) = 2029-02-28
Days between 2026-11-14 and 2027-01-01 = 48

Leap years behave the same way: adding one year to 29 February 2028 gives 28 February 2029. Computing each date from the start date with AddMonths(i) keeps the day; adding one month to the previous result loses the day at the first short month and never brings it back. For recurring dates such as installment plans or subscription renewals, compute each installment from the start. If "the last day of every month" is a business rule, express it explicitly with EndOfMonth; do not rely on 31 January happening to be a month end. You can get the difference between two days from the difference of their DayNumber values; with no time or zone involved, DateOnly is the cleanest tool here.

Age calculation is a similar trap. Some of Bookstore's books are sold only to customers aged 18 or over:

static int AgeOn(DateOnly birthDate, DateOnly today)
{
    int age = today.Year - birthDate.Year;
    if (birthDate > today.AddYears(-age))
        age--;
    return age;
}

DateOnly ada = new(2008, 11, 15);
DateOnly leapling = new(2008, 2, 29);
Console.WriteLine($"Ada on 2026-11-14     : {AgeOn(ada, new DateOnly(2026, 11, 14))}");
Console.WriteLine($"Ada on 2026-11-15     : {AgeOn(ada, new DateOnly(2026, 11, 15))}");
Console.WriteLine($"29 Feb on 2026-02-28  : {AgeOn(leapling, new DateOnly(2026, 2, 28))}");
Console.WriteLine($"29 Feb on 2026-03-01  : {AgeOn(leapling, new DateOnly(2026, 3, 1))}");
Console.WriteLine($"Naive days/365.25 on 2026-11-15: {(int)((new DateOnly(2026, 11, 15).DayNumber - ada.DayNumber) / 365.25)}");
Ada on 2026-11-14     : 17
Ada on 2026-11-15     : 18
29 Feb on 2026-02-28  : 17
29 Feb on 2026-03-01  : 18
Naive days/365.25 on 2026-11-15: 17

The formula that divides the day count by 365.25 still says 17 on Ada's 18th birthday; these 18 years contain only four leap days, while the formula assumes four and a half. The comparison based on calendar years gives the right answer. Someone born on 29 February turns a year older on 1 March in non-leap years; the law of some countries uses 28 February instead. That is a legal question rather than a coding one, but the decision must be made explicitly somewhere in the code.

Finally, TimeSpan. Let us format the 26 hours and 5 minutes a parcel spent in transit:

TimeSpan transit = TimeSpan.FromHours(26) + TimeSpan.FromMinutes(5);
CultureInfo tr = CultureInfo.GetCultureInfo("tr-TR");

Console.WriteLine($"ToString()         : {transit}");
Console.WriteLine($"\"c\" / \"g\" (tr-TR)  : {transit.ToString("c")} / {transit.ToString("g", tr)}");
Console.WriteLine($"@\"hh\\:mm\"          : {transit.ToString(@"hh\:mm")}");
Console.WriteLine($"@\"d\\.hh\\:mm\"       : {transit.ToString(@"d\.hh\:mm")}");
Console.WriteLine($"TotalHours based   : {(int)transit.TotalHours}:{transit.Minutes:00}");
Console.WriteLine($"Hours / TotalHours : {transit.Hours} / {transit.TotalHours.ToString(Invariant)}");
Console.WriteLine($"Parse(\"1.02:05\")   : {TimeSpan.Parse("1.02:05", Invariant).TotalMinutes} minutes");
Console.WriteLine($"FromMilliseconds   : {TimeSpan.FromMilliseconds(1500)}");
ToString()         : 1.02:05:00
"c" / "g" (tr-TR)  : 1.02:05:00 / 1:2:05:00
@"hh\:mm"          : 02:05
@"d\.hh\:mm"       : 1.02:05
TotalHours based   : 26:05
Hours / TotalHours : 2 / 26.083333333333332
Parse("1.02:05")   : 1565 minutes
FromMilliseconds   : 00:00:01.5000000

hh does not write the total hours but the hour component left after the days are taken out; a 26-hour duration shows up as "02:05" and nobody notices. The same distinction exists in the properties: Hours is 2, TotalHours is 26.08. In custom TimeSpan formats you must escape separators such as : and . with a backslash; a verbatim string (@"...") keeps that readable. Format durations that may exceed 24 hours yourself, based on TotalHours. The "c" format is culture-independent and suits logs and files; "g" changes with the culture.

Bookstore: Delivery Date = 5 Business Days After Approval

For corporate orders the contract says: the delivery date is 5 business days after approval; an approval given after 17:00 counts from the next day; weekends and public holidays are not business days. The calculation uses Istanbul time, independent of where the server runs. The first version that comes to mind starts with DateTime.Now and cannot be tested: the test result depends on the day and time you run it. Instead we write a class that receives "now" from outside:

public sealed class DeliveryDateCalculator(TimeProvider clock, BusinessCalendar calendar, TimeZoneInfo zone)
{
    public static readonly TimeOnly CutOff = new(17, 0);

    public DeliveryQuote QuoteForApprovalNow(int businessDays = 5)
    {
        DateTimeOffset approvedAt = TimeZoneInfo.ConvertTime(clock.GetUtcNow(), zone);
        DateOnly approvalDay = DateOnly.FromDateTime(approvedAt.DateTime);
        bool afterCutOff = TimeOnly.FromDateTime(approvedAt.DateTime) >= CutOff;

        DateOnly countFrom = afterCutOff ? approvalDay.AddDays(1) : approvalDay;
        DateOnly delivery = calendar.AddBusinessDays(countFrom, businessDays);
        return new DeliveryQuote(approvedAt, afterCutOff, delivery);
    }
}

public sealed record DeliveryQuote(DateTimeOffset ApprovedAt, bool AfterCutOff, DateOnly DeliveryDate);
public bool IsBusinessDay(DateOnly day) =>
    day.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday) && !_holidays.Contains(day);

public DateOnly AddBusinessDays(DateOnly start, int days)
{
    ArgumentOutOfRangeException.ThrowIfNegative(days);

    DateOnly day = start;
    while (days > 0)
    {
        day = day.AddDays(1);
        if (IsBusinessDay(day))
            days--;
    }
    return day;
}

TimeProvider is the abstract clock that arrived with .NET 8. In production you pass TimeProvider.System; in a test you set the time yourself with FakeTimeProvider from the Microsoft.Extensions.TimeProvider.Testing package. The async side of this class (timers, Task.Delay, pending work triggered by Advance) is in the C# Asynchronous Programming series post "C# Async and .NET 8: TimeProvider, ConfigureAwaitOptions, Parallel.ForAsync and CancelAsync"; here we only look at its synchronous use, the question "what time is it?". Note that the calculation does not use GetLocalNow(): that would depend on the local zone of the machine (or of the fake provider). The zone is part of the contract, so it comes in explicitly through the constructor.

TimeZoneInfo istanbul = TimeZoneInfo.FindSystemTimeZoneById("Europe/Istanbul");
BusinessCalendar calendar = new([new DateOnly(2026, 10, 29)]);

// In a test we decide what "now" is: Thursday 22 October 2026, 10:00 in Istanbul.
FakeTimeProvider clock = new(new DateTimeOffset(2026, 10, 22, 7, 0, 0, TimeSpan.Zero));
DeliveryDateCalculator calculator = new(clock, calendar, istanbul);

Print(calculator.QuoteForApprovalNow());
clock.Advance(TimeSpan.FromHours(8) + TimeSpan.FromMinutes(10));
Print(calculator.QuoteForApprovalNow());
clock.SetUtcNow(new DateTimeOffset(2026, 10, 26, 6, 0, 0, TimeSpan.Zero));
Print(calculator.QuoteForApprovalNow());

static void Print(DeliveryQuote quote) =>
    Console.WriteLine($"approved {quote.ApprovedAt:ddd yyyy-MM-dd HH:mm zzz} after cut-off={quote.AfterCutOff,-5} -> delivery {quote.DeliveryDate:ddd yyyy-MM-dd}");
approved Thu 2026-10-22 10:00 +03:00 after cut-off=False -> delivery Fri 2026-10-30
approved Thu 2026-10-22 18:10 +03:00 after cut-off=True  -> delivery Mon 2026-11-02
approved Mon 2026-10-26 09:00 +03:00 after cut-off=False -> delivery Tue 2026-11-03

The Thursday morning approval counts Friday, Monday, Tuesday, Wednesday and (skipping the 29 October Republic Day holiday) Friday: 30 October. An approval at 18:10 on the same day starts counting from Friday, and the delivery moves to Monday 2 November. The Monday morning approval also skips the holiday and ends on 3 November. All three cases were tested in a few milliseconds, without waiting for the real clock and with the same result on every run. In a unit test, each of these lines becomes an assertion. We passed the holiday list by hand here; in practice it comes from a configuration file or the database and needs updating every year. You may also need to add rules such as the half day before a holiday to this class.

In Production

Time zone rules change. Countries abolish daylight saving time, bring it back and change their offsets; Türkiye's 2016 decision also entered the tz database as an update. On Linux, .NET reads this data from the operating system's tzdata package; on Windows it arrives through Windows Update. If you do not rebuild a container image for months, you run with old rules; that is one more reason to follow base image updates. Small images such as Alpine may not have tzdata installed at all, in which case FindSystemTimeZoneById throws. Look up the zones you use once at startup and keep the result; you verify early that the rules exist and you do not look them up on every request (since .NET 8 the returned instance is cached anyway).

Converting a future local time (such as the time of next month's book signing) to UTC and storing it means storing the wrong instant if the rules change. In that case store the local time and the IANA id, and compute the instant when you need it; the time post in the Solution Patterns series covers this distinction together with the database side. For past instants, UTC or DateTimeOffset is enough.

For every piece of external data you parse, pass the format, the culture and the DateTimeStyles explicitly. The CA1305 rule covered in the Static Analysis series (it flags overloads that take no IFormatProvider) applies to parse calls too; turning it on as a build warning catches most of these bugs before code review.

Common Pitfalls

Writing tests with DateTime.Now. The test passes today and breaks on a Friday evening or on New Year's Eve. Take "now" from outside with TimeProvider and use FakeTimeProvider in tests.

Treating a Kind=Unspecified date as UTC. Text without an offset becomes Unspecified; ToUniversalTime() treats it as local time, and the result depends on the server's zone. If you know the text is UTC, use AssumeUniversal | AdjustToUniversal, and work with DateTimeOffset where you can.

Chaining AddMonths(1). A chain that starts on 31 January pulls every month to the 28th after 28 February. Compute recurring dates from the start and write the month-end rule explicitly.

Writing Windows ids to the database. They work on Linux too, but the Id of a zone with HasIanaId=false cannot be shared with other systems, and several IANA zones map to one region. Store the IANA id.

Hard-coding a fixed offset. "The warehouse is two hours behind" is true for part of the year. Compute the offset from the zone and the date.

Formatting durations with hh. Durations over 24 hours lose their day component; use TotalHours.

A Small Experiment

★☆☆ Add the ids "Asia/Kolkata" and "America/Sao_Paulo" to the date-zones example; print the Windows id of each and its UTC offset on 2026-07-01 (GetUtcOffset). Which one uses daylight saving time?

★★☆ Add a factory method to BusinessCalendar that reads the holiday list from a CSV line ("2026-10-29;2027-01-01") with DateOnly.ParseExact. When a bad date arrives, it should throw an exception that says which value is wrong.

★★★ Extend DeliveryDateCalculator for the Frankfurt warehouse: the warehouse applies its own holiday calendar and its own cut-off at 15:00 Berlin time. Build three scenarios with FakeTimeProvider where the approval falls on the night of the clock change on 25 October 2026, and check the results.

After time, we move on to the environment the application runs in. In the next post we read environment variables and platform information with Environment, run an external tool safely with Process and capture its output, look at the new Process APIs in .NET 11, and measure time correctly with Stopwatch.