Date, Time, and Timezones in Business Apps — From DateTime Pitfalls to the UTC-Storage Principle and Test Design
· Go Komura · C#, .NET, .NET Framework, Windows, Timezones, Date/Time Handling, Testing, Operations, Technical Consulting
“We moved the server to a cloud VM, and every timestamp on our reports shifted by nine hours.” “Only the devices we shipped to our overseas office show the previous day’s date on the daily report.” “There are signs that the late-night aggregation batch ran twice on one particular day.” Requests about date/time and timezone issues tend to arrive in this exact shape: “the moment the environment changed.” Not a single line of code was touched.
It’s tempting to assume “our app is only used domestically in Japan, so timezones don’t matter to us,” but in practice a large share of these consultations happen with exactly that kind of domestic-only app. Cloud VMs and containers are frequently provisioned with UTC as the system setting, and foreign libraries or SaaS web APIs return timestamps in UTC or with an explicit offset. The app itself may believe it lives entirely in Japan Standard Time, but the moment you step across a boundary, you’re already in a UTC world. If values are passed around without ever stating “what is this time measured against,” the mismatch stays hidden until the day of a server move or cloud migration, when it surfaces all at once as a nine-hour shift.
This article assumes a .NET business application and works through the Kind property of DateTime and its implicit-conversion traps, when to reach for DateTimeOffset instead, the principle of “store and transmit in UTC or with an explicit offset, convert to local time only for display,” TimeZoneInfo and daylight saving time, the boundary with the database, and finally test design using TimeProvider. We’ll end by distilling the items we check on every design review into a checklist.
1. The short version
- The root cause of nearly every date/time incident is the same: a value that carries no information about “what time reference is this measured against” crosses a boundary (a database, an API, a file). The symptom doesn’t appear when the code changes — it appears when the environment changes.
DateTimecarries an attribute called Kind (Utc / Local / Unspecified), and it defaults to Unspecified.ToLocalTimetreats Unspecified as UTC, whileToUniversalTimetreats it as local time — this asymmetric implicit interpretation is the classic source of the “nine-hour shift.”1- For new code, the default should be
DateTimeOffset. The official guidance explicitly states that it “should be considered the default date/time type for application development.”2 - The principle is “store and transmit in UTC or with an explicit offset; convert to local time only for display.” When turning a value into a string, write it out with the ISO 8601-compliant round-trip format string “o”, and read it back with
DateTimeStyles.RoundtripKind.3 - Timezone conversion is done through
TimeZoneInfo. Starting with .NET 6, both IANA IDs (Asia/Tokyo) and Windows IDs (Tokyo Standard Time) can be used, and APIs to convert between the two are also available.4 However, resolving IANA IDs on Windows depends on ICU, and it fails on older Windows Server builds or in invariant globalization mode (Chapter 4).5 - Even though Japan has no daylight saving time, the moment a device at an overseas office, an overseas SaaS integration, or a UTC-configured server enters the picture, you run headfirst into DST’s “nonexistent time” and “ambiguous time.”6
- Stop hardcoding
DateTime.Now. InjectTimeProvider(standard in .NET 8; use Microsoft.Bcl.TimeProvider on older targets) so tests can move the clock freely.7
2. DateTime’s Kind — what the three values mean, and the implicit-conversion incident
In addition to its date/time value (Ticks), DateTime carries exactly one extra attribute called Kind. It has three possible values, and the default is Unspecified.1
| Kind | Meaning | Typical source |
|---|---|---|
| Utc | Time measured against UTC | DateTime.UtcNow, the result of ToUniversalTime() |
| Local | Time measured against the executing machine’s local timezone | DateTime.Now, the result of ToLocalTime() |
| Unspecified | No reference is known (default) | new DateTime(...), DateTime.Parse (in most cases), values read from a database |
What matters is that ordinary code ends up producing Unspecified values everywhere. Values built with a constructor, values parsed from a string, and values read from a database are all, by default, “of unknown reference.” That in itself isn’t the problem. The problem is that conversion methods silently assume a reference for these Unspecified values.1
| Call | Kind=Utc | Kind=Local | Kind=Unspecified |
|---|---|---|---|
ToUniversalTime() |
returned as-is | converted to UTC | treated as local time and converted to UTC |
ToLocalTime() |
converted to local time | returned as-is | treated as UTC and converted to local time |
The key point is that the same Unspecified value can be interpreted as either “local” or “UTC” depending on which method you call. In code, it looks like this:
// A value read from the database. Along many code paths this ends up with Kind = Unspecified
var fromDb = new DateTime(2026, 7, 3, 9, 0, 0);
// If the executing machine is set to JST (UTC+9):
Console.WriteLine(fromDb.ToLocalTime()); // 18:00 -- treated as UTC, so +9 hours
Console.WriteLine(fromDb.ToUniversalTime()); // 00:00 -- treated as local time, so -9 hours
This is exactly the shape the incident takes. If a value stored in the database as Japan local time, 09:00 (Unspecified), gets run through a “just-in-case” ToLocalTime() call right before display, it’s treated as UTC and becomes 18:00. Conversely, if a conversion meant to “normalize to UTC before saving” ends up duplicated somewhere along the path, nine hours gets subtracted twice. What makes this even more troublesome is that the behavior depends on the executing machine’s timezone setting. On a development machine (JST) it shifts by nine hours; on a UTC-configured server it shifts by zero — which produces the classic “I can’t reproduce it on my machine” report. The “nine-hour shift after a server migration” mentioned at the top of this article is, more often than not, exactly this pattern.
2.1 The difference from DateTimeOffset, and when to use which
DateTimeOffset always carries a UTC offset (such as +09:00) alongside the date/time, so the value alone uniquely identifies a specific moment anywhere in the world. For use cases that are “recording a moment” — log entries, transaction times, system event records — the official guidance explicitly states that DateTimeOffset should be considered the default date/time type.2 Since there’s no room left for the implicit interpretation of Kind to sneak in, most of the incidents this article covers become structurally impossible.
That said, it isn’t a silver bullet. Keep in mind that what DateTimeOffset carries is an offset, not a timezone. +09:00 doesn’t tell you whether you’re in Japan or Korea, and it carries no daylight-saving adjustment rules.2 If you need to reproduce “what the wall clock in that particular place would show,” you still need to combine it with TimeZoneInfo, covered later. Here’s a table summarizing when to use which.
| Type | Information carried | Well suited for | Notes |
|---|---|---|---|
DateTimeOffset |
Date/time + UTC offset | Recording when something happened, logs, API boundaries | Default for new code2 |
DateTime (operated as Kind=Utc) |
Date/time only | Internal calculations, compatibility with existing assets | You manage Kind entirely yourself |
DateOnly / TimeOnly |
Date only / time only | Business dates, business hours, cutoff times | Not available in .NET Framework2 |
TimeSpan |
A duration | Elapsed time, the difference between two moments | |
TimeZoneInfo |
A timezone definition (including adjustment rules) | Conversion, determining DST | Chapter 4 |
Rewriting every existing DateTime asset to DateTimeOffset often isn’t realistic, so the compromise we commonly adopt on modernization projects is: “keep the internals and storage unified on DateTime with Kind=Utc, and use DateTimeOffset or an “o”-format string at boundaries (APIs, serialization).” Either way, the principle in the next chapter is the foundation underneath it.
3. The principle — store and transmit in UTC or with an offset; convert to local time only for display
The design principle for date/time handling fits in three lines.
- Capture the moment something happened with
DateTime.UtcNoworDateTimeOffset.UtcNow, and carry it around in UTC (or with an offset) the whole way. - When crossing a boundary (database, API, file, registry), state the format and reference explicitly as part of the spec.
- Convert to local time exactly once — right before it’s shown on a screen or printed on a report.
A design that stores local time makes the “meaning” of a value depend on an external piece of state: the server’s OS setting. As long as it runs on an on-premises server configured for Japan, the problem stays invisible — but a migration to a cloud VM, a DR configuration in an overseas region, or a configuration mismatch between dev and production will change that meaning, and any one of them is enough. With UTC storage, a value’s meaning is the same in every environment. The conversion for display happens on the consuming side (based on the user’s setting, or a site timezone stored in the user master), so viewing the same data from Tokyo or from Berlin each shows the correct wall-clock time for that location.
3.1 Use ISO 8601 / “o” format for string representations at boundaries
When a boundary is crossed as a string (JSON, CSV, logs, config files), use the ISO 8601-compliant round-trip format string “o”. “o” preserves DateTime’s Kind and DateTimeOffset’s offset in the string, and parsing with DateTimeStyles.RoundtripKind restores the original value.3
using System.Globalization;
// Writing out: 2026-07-03T13:30:00.0000000+09:00
DateTimeOffset now = DateTimeOffset.Now;
string s = now.ToString("o", CultureInfo.InvariantCulture);
// Reading back: restored with the offset preserved
var restored = DateTimeOffset.Parse(
s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
Always pair this with CultureInfo.InvariantCulture. Leaving the culture at its default means the year notation can change on a device running under a non-Gregorian culture, such as the Japanese imperial era calendar. What you must never do is store or transmit data using a format that carries no reference information at all, such as "yyyy/MM/dd HH:mm". Whoever receives that string has no choice but to guess “what is this measured against,” and guesses stop being right the moment the environment changes. Note also that System.Text.Json’s default date/time representation is itself ISO 8601-based, so the safe move at a JSON boundary is simply to go with the default.
The idea that “data crossing a boundary must have its format stated explicitly in the spec” isn’t unique to date/time. We make exactly the same argument about character encoding and line endings in “Character Encoding and Line Endings on Windows.” Implicit assumptions at a boundary break in the same shape, regardless of what kind of data is involved.
3.2 Distinguish a timestamp from a “business date”
Here’s one more distinction, needed to explain the “only the overseas office’s daily report date rolls back to the previous day” example from the introduction. A timestamp (a moment that’s unique worldwide) and a business date (a label like “the daily report for July 3”) are two different things. If you hold a business date as “a DateTime at midnight” and run it through a UTC conversion, midnight on July 3 at UTC+9 becomes 15:00 on July 2 in UTC — and the instant you slice out just the date portion, it shifts back a day. That’s the classic mechanism behind this bug.
Hold a business date as DateOnly (or, in .NET Framework, as a yyyy-MM-dd-formatted string or a plain year/month/day value), and decide, as part of the spec, which timezone the date gets cut against. As long as the spec states something like “the daily report date is based on the site’s local time” or “the cutoff is based on JST at head office,” the implementation is simply: convert UtcNow to the relevant timezone, then cut the date. If the spec doesn’t say, then the implementer’s own machine setting has become the spec by default.
4. Timezone conversion — TimeZoneInfo and its ID systems
Timezone conversion is done with TimeZoneInfo’s ConvertTimeFromUtc / ConvertTimeToUtc / ConvertTime. What you need to watch for is that these APIs validate the consistency between the DateTime’s Kind and the source timezone being converted from. For instance, passing a Kind=Utc value while specifying “the source is Tokyo” throws an ArgumentException.8 In other words, code that’s sloppy about managing Kind can’t even call the timezone conversion APIs properly. This ties directly back to Chapter 2.
4.1 Windows timezone IDs and IANA IDs
There are two ID systems for specifying a timezone.
| Windows ID | IANA ID | |
|---|---|---|
| Example (Japan) | Tokyo Standard Time |
Asia/Tokyo |
| Example (Germany) | W. Europe Standard Time |
Europe/Berlin |
| Managed by | Windows (registry) | The IANA tz database |
| Primarily used by | Windows APIs, .NET (Framework) | Linux, overseas SaaS, web APIs, other languages |
In the .NET Framework era, only Windows IDs were usable, which caused the recurring “the web API sends Asia/Tokyo, but I can’t pass that to FindSystemTimeZoneById” conversion-table problem. Starting with .NET 6, TimeZoneInfo.FindSystemTimeZoneById accepts both ID systems, and if the ID it’s given isn’t registered locally, it automatically converts and resolves it. TryConvertIanaIdToWindowsId / TryConvertWindowsIdToIanaId were also added, for cases where you want to convert explicitly.4
// .NET 6+: an IANA ID works as-is (the Windows ID "Tokyo Standard Time" produces the same result)
var tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
DateTime utc = DateTime.UtcNow;
Console.WriteLine(TimeZoneInfo.ConvertTimeFromUtc(utc, tokyo)); // Tokyo's wall-clock time
Console.WriteLine(TimeZoneInfo.ConvertTimeFromUtc(utc, berlin)); // Berlin's wall-clock time
// Converting between ID systems (.NET 6+)
if (TimeZoneInfo.TryConvertWindowsIdToIanaId("Tokyo Standard Time", out var ianaId))
Console.WriteLine(ianaId); // Asia/Tokyo
As a practical rule of thumb, we recommend standardizing on IANA IDs for the timezone IDs kept in your site master data or config files. IANA IDs are what’s universally understood across overseas SaaS, Linux containers, and other languages, and on .NET 6 and later they can generally be accepted as-is. If part of your system is still running on .NET Framework, convert to a Windows ID only at that specific boundary. Note also that FindSystemTimeZoneById throws TimeZoneNotFoundException if the ID can’t be found, so catch that early — either in the screen where site IDs are registered, or during startup validation.
There’s one important precondition to be aware of. Resolving IANA IDs on Windows depends on the ICU library. Apps running under NLS mode or invariant globalization mode (InvariantGlobalization=true) cannot resolve IANA IDs, and TryConvertIanaIdToWindowsId fails too.5 The same constraint also applies when running .NET 6 on an older OS whose ICU isn’t bundled with the system (Windows Server 2019, Windows 10 builds 1809 and earlier, and so on) unless you bundle an app-local ICU (this was changed starting with .NET 7, so ICU is used on these OS versions as well9). In other words, “Asia/Tokyo resolves fine on my dev machine (Windows 11), but throws TimeZoneNotFoundException” is a real thing that happens specifically on a customer’s Windows Server 2019 box. If you adopt IANA IDs for your master data, pair that decision with three things: (1) confirm IANA resolution actually works on the OS and .NET version you’ll run on, (2) don’t casually enable InvariantGlobalization in a container just to shrink the image, and (3) as insurance, build a fallback into your startup validation that falls back to a Windows ID via TryConvertIanaIdToWindowsId and retries.
5. Daylight saving time (DST) — you’ll hit it even in a Japan-only app
Because Japan currently has no daylight saving time, it’s easy to assume “DST has nothing to do with us.” But you will absolutely hit it if any of the following apply:
- The app runs on devices used at overseas offices or by employees traveling abroad (the device’s local timezone observes DST)
- You integrate with overseas SaaS or web APIs and receive timestamps or schedules based on local time
- Aggregation or batch jobs run on servers or VMs in an overseas region
- You have a cutoff based on local time at an overseas office (such as “each site cuts off at midnight local time”)
In timezones that observe DST, the day of the switch produces two kinds of abnormal times. Nonexistent times (in spring, the band of time skipped over as the clock jumps forward — in Germany, 02:00–03:00 in late March) and ambiguous times (in autumn, the band that occurs twice because the clock is turned back). In .NET, these can be detected with TimeZoneInfo.IsInvalidTime / IsAmbiguousTime.6 And conversion APIs such as ConvertTimeToUtc throw an ArgumentException when passed a nonexistent time, and interpret an ambiguous time as standard time.8
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
// 2026-03-29 is Germany's DST start date. Local time between 02:00 and 03:00 does not exist
var t = new DateTime(2026, 3, 29, 2, 30, 0); // Kind = Unspecified
Console.WriteLine(berlin.IsInvalidTime(t)); // True
// TimeZoneInfo.ConvertTimeToUtc(t, berlin) throws an ArgumentException
If there’s an input path that “takes a local-time string and converts it to UTC before saving,” this exception lies in wait as a bug that only manifests one day a year. A realistic way to land this is to check IsInvalidTime during input validation, and to state explicitly in the spec that an ambiguous time should be “treated as standard time.”
5.1 Scheduled jobs and DST — the “runs twice / doesn’t run” problem
Scheduled jobs are another classic place to get hit. A job that runs daily at 02:30 local time simply doesn’t have that time available on the day DST begins, and has it twice on the day it ends. Depending on how the scheduler is implemented, the behavior varies — “it gets skipped,” “it fires twice,” “it fires an hour off” — which means an aggregation process written under the assumption of “runs exactly once a day” ends up double-counting or missing data entirely. The countermeasure is a combination of three things:
- Base the schedule on UTC (or a timezone with no DST). For batch jobs where firing at a specific local time isn’t actually a requirement, this alone makes the problem disappear.
- Make the process idempotent. If you add an “already-run” marker — skip if the aggregation for the target date already exists — firing twice stops being harmful.
- Key the aggregation by business date (Section 3.2). Don’t back-calculate the date from the firing time.
We cover the design of scheduled execution with Task Scheduler (preventing duplicate launches, isolating failures) in detail in “Task Scheduler Tasks Not Running, or Exiting with 0x1,” and the design of holding a timer inside a resident service in “How to Build and Operate a Windows Service.” Whichever approach you use, the relationship between DST and the schedule needs to be written into the spec in the same way.
6. The boundary with the database — SQL Server / SQLite / ORMs
Of all the boundaries, the database is where the most incidents happen. Most database date/time types don’t preserve “what reference this is measured against,” so Kind and offset information is lost the instant a value is stored.
6.1 SQL Server’s date/time types
| Type | Range/precision | Reference information | Guidance for new use |
|---|---|---|---|
datetime |
From 1753, roughly 1/300-second precision | None | Avoid (officially documented as not recommended for new development)10 |
datetime2 |
From year 0001, up to 100-nanosecond precision | None | The default choice for a column stored in UTC10 |
datetimeoffset |
datetime2-equivalent + offset |
Preserves the offset | For columns where reproducing local time is a requirement10 |
datetime is a legacy type with coarse rounding and a narrow range, and the official documentation explicitly states that “new work should avoid datetime and use datetime2 / datetimeoffset instead.”10 There’s no need to force a migration of an existing schema’s datetime columns, but there’s no reason to choose it for a new table either.
Whether to store UTC in datetime2 or use datetimeoffset comes down to “does the offset at the moment of input need to be reproducible later?” If audit or compliance requirements call for preserving “what time it was in the user’s local time,” use datetimeoffset; if all you need is to pin down the moment, UTC in datetime2 is enough. Note that, just like DateTimeOffset, what datetimeoffset carries is only the offset, not the timezone (adjustment rules) itself. If you need the zone as well, keep the IANA ID in a separate column.
6.2 SQLite has no date/time type
SQLite has no storage type for date/time at all, and Microsoft.Data.Sqlite stores DateTime / DateTimeOffset as TEXT.11 TEXT in an ISO 8601-style format means string sort order equals chronological order, “as long as the format and timezone are consistent” — but conversely, the moment UTC and local time end up mixed in the same column, both sorting and range queries break silently. If you’re using SQLite, the only real option is to fix, as an application-level convention, “this column is UTC, and this is its format.” We’ve written up the practical side of SQLite — including connections and transactions — in “Using SQLite in a C# Business Application.”
6.3 Watch out with EF Core / Dapper — Kind disappears on read
Reading a DateTime back out of a type that carries no reference information (datetime2, SQLite’s TEXT, and so on) naturally produces Kind = Unspecified. This is exactly how the incident arises where “we standardized on UTC for storage, but somewhere a value read back out gets run through ToUniversalTime(), producing a double conversion.” The fix is to restore Kind at the boundary. With EF Core, you can declare this once, in bulk, via a value converter.
// EF Core: declare "this column is UTC" once at the model level
modelBuilder.Entity<Order>()
.Property(o => o.CreatedAtUtc)
.HasConversion(
// On write: normalize anything that isn't UTC (including stray DateTime.Now values)
// to UTC at the boundary. Note that Unspecified is treated as local time when converted
v => v.Kind == DateTimeKind.Utc ? v : v.ToUniversalTime(),
// On read: restore Kind
v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
Treat the normalization on the write side purely as a last-resort safety net. Because converting an Unspecified value depends on the executing machine’s timezone setting, you should still find and fix any code that feeds DateTime.Now or Unspecified values into the storage path directly. Think of it as a two-layer defense: the safety net plus the convention. With Dapper or plain ADO.NET, gather the mapping step’s DateTime.SpecifyKind call into a single conversion layer right after the data is mapped. A naming convention helps too — just baking the reference into the column and property name (CreatedAtUtc, updated_at_utc) substantially raises the odds that a reviewer notices “calling ToUniversalTime on this value looks wrong,” since names get read far more often than documentation does.
7. Clock synchronization and testing — w32time and TimeProvider
7.1 Question the assumption that the machine’s clock is correct
Everything up to this point assumed “the machine’s clock itself is correct” — but what keeps that clock correct is the Windows Time service (w32time). w32time synchronizes with a network time source over NTP, and in an Active Directory environment it synchronizes along the domain hierarchy. It’s the foundation for anything sensitive to clock drift, Kerberos authentication included.12
This has two implications for application design. First, don’t treat the client PC’s clock as authoritative for business logic. A device whose sync has stopped can easily drift by several minutes, so event ordering and cutoff-time decisions should be made using server-side time, with client-side time treated as informational only. Second, when someone reports “the time looks wrong,” check the device’s sync state with w32tm /query /status before you even start investigating the app. It takes a minute and rules out a whole category of cause before you dive into an app bug hunt.
7.2 Stop hardcoding DateTime.Now — TimeProvider
The single biggest obstacle to testing date/time logic is DateTime.Now hardcoded all over the codebase. If you want to test “month-end cutoff detection,” “sequence-number resets across a year boundary,” or “the schedule around a DST switchover day,” but you can’t pin the current time down, you’re stuck waiting for that actual day to arrive.
.NET 8 introduced the standard time abstraction TimeProvider. It lets you swap out GetUtcNow() / GetLocalNow() / LocalTimeZone / timer creation through a single abstraction. The same type is also available on .NET Framework 4.6.2 and later, and on .NET Standard 2.0, via the Microsoft.Bcl.TimeProvider NuGet package, so it can be introduced into older codebases too. A test implementation, FakeTimeProvider, is provided by the Microsoft.Extensions.TimeProvider.Testing package.7
public sealed class DailyReportService
{
private readonly TimeProvider _clock;
private readonly TimeZoneInfo _siteTimeZone;
public DailyReportService(TimeProvider clock, TimeZoneInfo siteTimeZone)
{
_clock = clock;
_siteTimeZone = siteTimeZone;
}
// An implementation that explicitly states "the business date is cut using the site's timezone" (Section 3.2)
public string GetReportDateKey()
{
var localNow = TimeZoneInfo.ConvertTime(_clock.GetUtcNow(), _siteTimeZone);
return localNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
}
}
In production, pass in TimeProvider.System; in tests, use FakeTimeProvider to pin and advance the clock freely.
[Fact]
public void BusinessDateSwitchesCorrectlyAcrossTheYearBoundary()
{
// Start pinned at 23:30 JST on New Year's Eve
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 the year rollover instantly
Assert.Equal("2027-01-01", svc.GetReportDateKey());
}
FakeTimeProvider can also swap out the local timezone (SetLocalTimeZone) in addition to pinning and manually advancing the clock, so a bug like “dates roll back a day on a device configured for Germany” can be reproduced on a Japan-configured CI machine. If you’re on an older .NET Framework project where even adding a NuGet package is difficult, a self-rolled IClock interface with nothing but DateTimeOffset UtcNow { get; } achieves the same effect. What matters isn’t how elaborate the abstraction is — it’s treating “the current time” as an injectable dependency.
Based on experience, these five moments are the bare minimum to cover with test cases: the New Year boundary (year rollover), month-end (the 31st, the 30th, and February), February 29 in a leap year, the target timezone’s DST switchover days (spring and autumn), and around midnight (the business-date boundary). Every one of these is a classic habitat for bugs that only fire “on that particular day,” and with FakeTimeProvider you can test all of them in a few milliseconds.
8. Checklist and decision table
Date/time handling is, much like UUIDs, foundational infrastructure that tends to get left alone with a “well, it seems to work” shrug (a structure very similar to what we wrote about in “Do UUIDs Really Never Collide?”). Fixing the items you look at during new design and code review removes a lot of the dependence on any one person’s judgment.
Checklist for new design work:
- Is capturing the current moment standardized on
DateTimeOffset.UtcNow/TimeProvider.GetUtcNow()? - Is the reference for storage/transmission (UTC vs. offset-carrying) and the format (“o” / ISO 8601) documented explicitly in the spec?
- Have the database column types and reference been decided (for SQL Server,
datetime2(UTC) ordatetimeoffset; bakeUtcinto the column name)? - Is a distinction drawn between timestamps and business dates, and has the timezone used to cut the date been decided?
- Has a management scheme for timezone IDs (IANA IDs recommended) and error handling for an invalid ID been decided?
- Has a DST policy for scheduled execution (UTC-based scheduling plus idempotency) been decided?
- Is
TimeProvider/IClockinjectable, and do test cases exist for time boundaries?
Code worth grepping for during review:
| If you find this code, be suspicious | What can go wrong | How to fix it |
|---|---|---|
DateTime.Now |
Drifts after a server migration. Untestable | UtcNow + convert only for display. Inject TimeProvider |
ToLocalTime() / ToUniversalTime() |
Implicit interpretation of Unspecified (Chapter 2) | Pin down Kind at the boundary; convert only right before display |
DateTime.Parse(s) (no styles specified) |
Depends on the executing environment’s culture/timezone | ParseExact + InvariantCulture + RoundtripKind |
Storing/transmitting via ToString("yyyy/MM/dd HH:mm") |
Reference information is lost | The “o” format + InvariantCulture |
A new DateTime(...) value used directly in comparisons or storage |
Unspecified values creep in | Apply SpecifyKind, or move to DateTimeOffset |
A new datetime column in SQL Server |
Precision, range, and future-proofing concerns | datetime2 / datetimeoffset10 |
Most of this checklist can be decided within the first day of a new development effort. By contrast, fixing it after the system is already live turns into archaeology — inferring, for stored data, which period was saved under which reference, then migrating it — and that changes the cost by an order of magnitude.
9. Summary
Date/time and timezone incidents show up as a grab bag of symptoms — “off by nine hours,” “the date rolls back a day,” “the batch ran twice” — but the cause is consistently the same: a value with no reference information crossing a boundary. So is the fix. Capture in UTC; store and transmit in UTC or with an offset, using ISO 8601 (“o”); convert to local time only for display. State the format and reference explicitly in the spec at every boundary, and bake the reference into database column names. Handle timezones through TimeZoneInfo and IANA IDs, and catch DST’s nonexistent and ambiguous times with input validation and idempotent scheduled execution. And inject TimeProvider so that both the year rollover and DST switchover days can be reproduced in tests. Do all of this, and you move from being the one who gets called in after the environment changes, to the one who flags the problem before it does.
We handle root-cause investigations of time drift caused by server migrations or cloud moves, design reviews for date/time handling, and modernization support for overseas-office readiness (timezone and DST support). If your stored data already has a mix of references and you’re not sure how to sort it out, we can help with the inventory and migration planning too.
Related Articles
- Task Scheduler Tasks Not Running, or Exiting with 0x1 — Isolating the Cause and a Reliable Operational Design
- Using SQLite in a C# Business Application — WAL Mode, Locking, Corruption Countermeasures, and When to Use EF Core Instead
- How to Build and Operate a Windows Service — From Choosing Between Task Scheduler to Turning a BackgroundService into a Service
- Character Encoding and Line Endings on Windows - The Basics of Mojibake and CRLF/LF
Related Consulting Areas
Komura Soft LLC handles design reviews for date/time and timezone handling in business applications, root-cause investigations of time and date drift caused by server migrations or cloud moves, and modernization of date/time handling for overseas-office support and legacy assets.
- Technical Consulting & Design Review
- Windows Application Development
- Legacy Asset Utilization & Migration Support
- Contact Us
References
-
Microsoft Learn, DateTime.Kind Property. On Kind defaulting to Unspecified, and on how Kind affects the result of ToLocalTime / ToUniversalTime (Unspecified being treated as UTC by ToLocalTime and as local time by ToUniversalTime). ↩ ↩2 ↩3
-
Microsoft Learn, Choose between DateTime, DateOnly, DateTimeOffset, TimeSpan, TimeOnly, and TimeZoneInfo. On DateTimeOffset being the recommended default date/time type for application development, DateTimeOffset carrying only an offset and not being tied to a timezone, and DateOnly / TimeOnly not being available in .NET Framework. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Standard date and time format strings. On the round-trip format “o” being ISO 8601-compliant and preserving DateTime’s Kind and DateTimeOffset’s offset in the string, and on round-tripping via parsing with DateTimeStyles.RoundtripKind. ↩ ↩2
-
Microsoft Learn, What’s new in .NET 6. On TimeZoneInfo.FindSystemTimeZoneById accepting both IANA and Windows timezone IDs with automatic conversion in .NET 6, and on the addition of TryConvertIanaIdToWindowsId / TryConvertWindowsIdToIanaId. ↩ ↩2
-
Microsoft Learn, .NET globalization and ICU. On IANA timezone ID resolution and the interconversion APIs on Windows depending on ICU, on their unavailability under NLS mode / invariant globalization mode, and on using an app-local ICU on OS versions without ICU. ↩ ↩2
-
Microsoft Learn, TimeZoneInfo.IsInvalidTime(DateTime) Method. On the definition and detection of the “nonexistent time” produced by the switch to daylight saving time, and its counterpart IsAmbiguousTime (detecting ambiguous times). ↩ ↩2
-
Microsoft Learn, What is TimeProvider?. On TimeProvider being built into .NET 8 and later, being available on .NET Framework 4.6.2+ / .NET Standard 2.0 via the Microsoft.Bcl.TimeProvider package, its GetUtcNow / GetLocalNow / LocalTimeZone / timer-creation abstraction, and the test-oriented FakeTimeProvider provided by the Microsoft.Extensions.TimeProvider.Testing package. ↩ ↩2
-
Microsoft Learn, TimeZoneInfo.ConvertTime Method. On the requirement that DateTime.Kind be consistent with the source timezone (mismatches throw ArgumentException), on ambiguous times being interpreted as standard time, and on nonexistent times throwing ArgumentException. ↩ ↩2
-
Microsoft Learn, Globalization APIs use ICU libraries on Windows Server 2019. On .NET 7 and later using ICU libraries even on Windows Server 2019 and similar OS versions that don’t bundle ICU, whereas earlier versions required manually deploying an app-local ICU. ↩
-
Microsoft Learn, datetime (Transact-SQL). On new work being advised to avoid datetime in favor of time / date / datetime2 / datetimeoffset, and on datetime2 / datetimeoffset offering higher precision with datetimeoffset supporting a timezone offset. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Data types (Microsoft.Data.Sqlite). On SQLite having only four primitive storage types, and on Microsoft.Data.Sqlite storing DateTime / DateTimeOffset as TEXT. ↩
-
Microsoft Learn, Windows Time Service (W32Time). On the Windows Time service synchronizing computer clocks over a network via NTP, the synchronization hierarchy within an Active Directory domain, and Kerberos authentication’s dependence on time synchronization. ↩
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
WPF High-DPI Support — Why It Still Blurs and Bleeds Despite Being 'Supposedly DPI-Aware,' and How to Fix It
WPF lays out UI in DIPs (1/96 inch) and is System DPI Aware from the start, but moving a window to a monitor with a different DPI blurs t...
High-DPI Support in WinForms — Why the UI Blurs or Breaks on 4K Monitors, and Practical Fixes
This article organizes the reasons WinForms apps blur or have broken layouts on 4K monitors and 150% scaling, starting from DPI virtualiz...
Using SQLite from C# in Business Apps — WAL Mode, Exclusive Locking, Corruption Countermeasures, and When to Reach for EF Core
A practical rundown of embedding SQLite into a business Windows app with Microsoft.Data.Sqlite: connection strings and pooling, how WAL m...
How to Build and Operate Windows Services ── From Choosing Between Task Scheduler and Services to Turning a BackgroundService into a Windows Service
Should a background process become a Windows service, or is Task Scheduler enough? This guide organizes the practical design work for put...
How to Think About Windows Session Isolation — Session 0, RDP, and Running Multiple Users Concurrently
This article untangles the concept of a Windows "session," a topic that consistently confuses Windows app developers. It covers why Sessi...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
We support Windows desktop applications that involve resident processing, device integration, operational logging, and maintainable structure.
Author Profile
Profile page for the article author.
Go Komura
Representative of KomuraSoft LLC
Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.
Public links