Dates, times, and time zones¶
Targets: net10.0, csharp-14 · Last reviewed: 2026-09-02 · Sources: ms-learn, dotnet-blog, jon-skeet, meziantou, andrew-lock
Four rules carry most of the weight: DateTimeOffset is the default type, not DateTime; UTC is right for timestamps and wrong for future local events; services take an injected TimeProvider, never call DateTime.UtcNow; and a time zone is an IANA id (Europe/Amsterdam), never an abbreviation or an offset.
Choosing the type¶
Default to DateTimeOffset; pick a narrower type only when the data has no time, no date, or no clock. Microsoft says so outright: "consider DateTimeOffset as the default date and time type for application development". (Microsoft Learn: Compare types related to date and time)
| Need | Type | Why |
|---|---|---|
| An unambiguous point in time | DateTimeOffset |
Carries the UTC offset, so it identifies one instant on any machine |
| A whole date, no time (birthday, invoice) | DateOnly |
Cannot be shifted a day by a time zone; serializes less; matches SQL date |
| A time of day (opening hours, alarm) | TimeOnly |
Wraps within 24h instead of over/underflowing like TimeSpan or DateTime |
| A duration or elapsed time | TimeSpan |
It is an interval, not a clock reading |
| Zone rules and conversions | TimeZoneInfo |
The only type that knows about DST transitions |
DateTime |
rarely | Only for abstract dates, or UTC-only code where Kind is set to Utc |
Two caveats the documentation states explicitly:
DateTimeOffsetdoes not know its time zone. It records the offset that applied at one moment, and the same offset belongs to many zones: it "can't reflect a time zone's transition to and from daylight saving time". For arithmetic across a DST transition, convert to UTC, do the arithmetic there, and convert the result back to the zone withTimeZoneInfo. (Microsoft Learn: Compare types related to date and time, Microsoft Learn: How to use time zones in date and time arithmetic)DateTimewithKind = Unspecifiedis ambiguous even on the machine that produced it. If aDateTimemust cross a boundary, it is UTC withKind = Utcor it is a bug. (Microsoft Learn: Compare types related to date and time)
And the assumptions to stop making: offsets are not whole hours (Newfoundland is UTC−3:30), DST shifts are not always an hour (Lord Howe is 30 minutes), zones within one country differ on whether they observe DST at all, and abbreviations like "BST" are ambiguous across three real zones. Anything narrower than an IANA id loses information. (Meziantou: 39 misconceptions about date and time)
Persistence: split by kind of data, not by layer¶
Machine-generated timestamps are instants, so store them as UTC. Human-supplied future or recurring events are not instants until zone rules that may still change are applied, so store the local time and the IANA zone id, and treat UTC as derived. (Skeet: Storing UTC is not a silver bullet)
The failure the blanket "convert to UTC on the way in" advice invites: a conference registered for 9am Amsterdam on 2022-07-10 converts to 07:00Z under the tzdb rules of the day. If the Netherlands later drops summer time, the correct instant becomes 08:00Z, and a row storing only UTC is silently an hour wrong, with no way to recover the organiser's intent. The schema that survives rule changes stores what was supplied and caches the conversion:
LocalStart: 2022-07-10T09:00:00 ← what the user said; never mutated
TimeZoneId: Europe/Amsterdam ← IANA id, not an offset
UtcStart: 2022-07-10T07:00:00Z ← derived; recomputed when tzdb updates
TimeZoneRules: 2019a ← optional, for resumable re-derivation
The same holds for recurring events: "10 AM in New York every week" is a rule, not a series of instants. (Meziantou: 39 misconceptions) Two operational corollaries: IANA publishes multiple tzdb releases a year, sometimes days before they take effect, so updating the base image is also the trigger to re-derive the UTC column; and persist IANA ids, not Windows ids. .NET converts between the families, but Windows ids tie data to a platform.
Provider mechanics (Microsoft Learn: EF Core):
- EF Core 8+ maps
DateOnly↔ SQL ServerdateandTimeOnly↔time, and scaffolding generates those types instead ofDateTime/TimeSpan(.NET Blog: EF Core 8 Preview 1: Raw, lazy, and on-time). The oldDateTime-for-a-date mapping is a legacy shape. - SQL Server has a real
datetimeoffsetcolumn type; PostgreSQL does not. Do not assume aDateTimeOffsetproperty is portable across providers. Microsoft.Data.Sqlite10.0 convertsDateTimeOffsetto UTC before writing to REAL columns, a behaviour change to check if you target SQLite (Microsoft Learn: EF Core 10 breaking changes).- On the wire,
System.Text.Jsonround-tripsDateOnly/TimeOnlynatively since .NET 7, andDateTimeOffsetserializes as ISO 8601 with the offset. Minimal-API bodies therefore need no custom converters, and a wire format that omits the offset is pushing the ambiguity onto the client. (Microsoft Learn: DateTime and DateTimeOffset support in System.Text.Json)
When the BCL types aren't enough¶
Use NodaTime when the domain models future or recurring local-time events across zones, as in the schema above, and stay on the BCL types otherwise. NodaTime turns this file's conventions into compile-time properties: Instant for machine timestamps, LocalDateTime + DateTimeZone for the human-supplied columns, ZonedDateTime for the derived conversion. The argument that a local date and time is not yet an instant is the library's founding design case. The cost is an adapter at every boundary the BCL types cross natively: NodaTime.Serialization.SystemTextJson, per-provider EF Core plugins such as Npgsql.NodaTime, and model binding. That is why it is the exception and not the default, because DateTimeOffset, DateOnly/TimeOnly and TimeProvider cover the ordinary service from the BCL with nothing to add. Conflict of interest: the source cited here created NodaTime. (Skeet: More fun with DateTime)
Services: inject TimeProvider, stop writing your own clock¶
Take TimeProvider as a dependency and register TimeProvider.System once; never call DateTime.UtcNow or DateTimeOffset.Now in a service, and never hand-roll an IClock abstraction. It ships in the BCL from .NET 8 (Microsoft.Bcl.TimeProvider back to netstandard2.0), with GetUtcNow()/GetLocalNow() returning DateTimeOffset, GetTimestamp()/GetElapsedTime() for measurement, CreateTimer(...), and LocalTimeZone as the seam that lets one process serve users in different zones. (Microsoft Learn: What is TimeProvider?)
// Register once; TimeProvider.System is the production implementation.
builder.Services.AddSingleton(TimeProvider.System);
public sealed class OrderService(TimeProvider time)
{
public Order Place(Basket basket) => new(basket, PlacedAt: time.GetUtcNow());
}
Forward it rather than dropping it. Task.Delay, Task.WaitAsync, and CancellationTokenSource all have TimeProvider overloads, and a PeriodicTimer or BackgroundService built on the raw ones is untestable for no gain.
This is enforced, but by a pair of files, not one. templates/Directory.Build.props ships Meziantou.Analyzer with TreatWarningsAsErrors, and the date/time rules below ship at info severity or disabled, so on their own they never trip it. It is templates/.editorconfig's dotnet_analyzer_diagnostic.severity = warning that raises them to warnings, which TreatWarningsAsErrors then turns into build failures. Adopt both files, or a hand-rolled IClock builds clean. (Meziantou: Meziantou.Analyzer rules)
| Rule | Title | Default |
|---|---|---|
MA0188 |
Use System.TimeProvider instead of a custom time abstraction |
enabled, info severity |
MA0166 |
Forward the TimeProvider to methods that take one |
enabled, info severity |
MA0167 |
Use an overload with a TimeProvider argument |
disabled; templates/.editorconfig enables it too |
Testing time¶
Use FakeTimeProvider from Microsoft.Extensions.TimeProvider.Testing: set a start instant, advance manually with Advance(TimeSpan), and timers created from it fire as time moves. That is what makes retry, backoff, and scheduling logic testable without Thread.Sleep. One caveat: a single large Advance fires every elapsed callback at the boundary rather than spread out, so tests asserting interleaving should step time in small increments. (Lock: Avoiding flaky tests with TimeProvider and ITimer)
Two constructor overloads undermine the start instant. The parameterless one starts at midnight on 2000-01-01 UTC, a hardcoded date the test never states; it is tolerable only for a test that would pass at any instant, and even then naming a start costs one line and says so. Passing DateTimeOffset.UtcNow as the start reintroduces the real clock, so the test's dates change on every run and a DST or month boundary is crossed by luck. (Microsoft Learn: FakeTimeProvider constructors) testing.md has the one-clock rule this serves.
For date-sensitive logic, exercise the awkward instants deliberately: a DST spring-forward gap (a local time that does not exist), a fall-back overlap (a local time that happens twice), a leap day, and a year boundary that splits calendar year from ISO week year: 2022-01-02 is in week 52 of 2021. (Meziantou: 39 misconceptions) See testing.md for the framework stack this slots into.
UI and hosts¶
- The user's zone lives in the browser, not the server. In interactive-server Blazor, register a scoped per-circuit
TimeProviderfilled from JS interop. The pattern is in ui-frameworks.md. - Run hosts in UTC and convert at the edges. Nothing should depend on the server's own local zone: express a scheduled job's schedule in an explicit zone. Local time repeats an hour when daylight saving time ends and skips one when it starts, so a job pinned to server-local time in that hour runs twice or not at all. (Microsoft Learn: How to resolve ambiguous times, Microsoft Learn: TimeZoneInfo.IsInvalidTime)
- Container images must actually carry tzdata and ICU for any of the zone conversion above to work. Image tags, invariant mode and the failure modes are in globalization.md, along with the display-side rule: format for humans with their culture, for machines with the invariant one.
Smaller traps¶
TimeSpan.From*gained integer overloads in .NET 9 because thedoubleones are lossy:TimeSpan.FromSeconds(101.832)is not 101.832 seconds. In F# this broke overload resolution:TimeSpan.FromMinutes(20)now needs an explicit type annotation. (Microsoft Learn: Breaking change: New TimeSpan.From*() overloads that take integers)TimeZoneInfo.FindSystemTimeZoneByIdaccepts the platform's native ids, and the lookup itself needs only tzdata, so it works even in invariant-globalization mode. Converting between the IANA and Windows id families (TryConvertIanaIdToWindowsId/TryConvertWindowsIdToIanaId) is the ICU-dependent part, unavailable in invariant and NLS modes (globalization.md).