Skip to content

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:

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 Server date and TimeOnly ↔ time, and scaffolding generates those types instead of DateTime/TimeSpan (.NET Blog: EF Core 8 Preview 1: Raw, lazy, and on-time). The old DateTime-for-a-date mapping is a legacy shape.
  • SQL Server has a real datetimeoffset column type; PostgreSQL does not. Do not assume a DateTimeOffset property is portable across providers.
  • Microsoft.Data.Sqlite 10.0 converts DateTimeOffset to 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.Json round-trips DateOnly/TimeOnly natively since .NET 7, and DateTimeOffset serializes 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 TimeProvider filled 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 the double ones 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.FindSystemTimeZoneById accepts 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).