Time Zones & Dates Across Systems
The meeting shows at the wrong hour for half your users
Use Case: Time Zones & Dates Across Systems
The everyday problem: A user in Berlin schedules a meeting for "3 PM". A user in New York sees it. Your server runs in UTC in a cloud region somewhere else. The database stores a timestamp. A report sums "yesterday's" sales — but yesterday for whom? A daily job runs "at midnight" — whose midnight? Dates and times are deceptively hard: store them wrong and meetings appear at the wrong hour, "today" spans the wrong 24 hours, daylight-saving transitions duplicate or skip an hour, and bugs surface only for users in certain regions or at certain times of year. The challenge is handling dates and times correctly across time zones, storage, display, and scheduling — so every user sees the right local time and every calculation uses the right boundaries.
Five practices that together form a coherent date/time strategy. They are less "alternatives" and more "rules that compound" — but each addresses a distinct failure mode.
Way 1: Store UTC, Convert at the Edges
Best used for: The foundational rule for almost all timestamp storage — store every point-in-time in UTC, and convert to local time only when displaying to a user or accepting input from one.
The concept: A moment in time is absolute; how it is displayed is local. Store the absolute moment in UTC (Coordinated Universal Time — no offset, no DST), and treat local time as purely a presentation concern: convert UTC → user's zone when showing, and local → UTC when receiving. The database and all internal logic speak UTC exclusively.
// CAPTURE — always record the moment in UTC
var order = new Order
{
PlacedAt = DateTime.UtcNow // NOT DateTime.Now — never store server-local time
};
// Better: use DateTimeOffset to make the offset explicit and unambiguous
var created = DateTimeOffset.UtcNow;
// STORE — the database column holds UTC (timestamptz in PostgreSQL, datetimeoffset in SQL Server)
// DISPLAY — convert UTC to the USER's time zone at the edge
public string FormatForUser(DateTime utc, string userTimeZoneId)
{
var tz = TimeZoneInfo.FindSystemTimeZoneById(userTimeZoneId); // e.g. "Europe/Berlin"
var local = TimeZoneInfo.ConvertTimeFromUtc(utc, tz);
return local.ToString("f"); // formatted in the user's local time
}
// ACCEPT INPUT — convert the user's local time back to UTC for storage
public DateTime ParseFromUser(DateTime userLocal, string userTimeZoneId)
{
var tz = TimeZoneInfo.FindSystemTimeZoneById(userTimeZoneId);
return TimeZoneInfo.ConvertTimeToUtc(userLocal, tz);
}
Why it works: UTC is a single, unambiguous, monotonic reference with no DST jumps and no offset — so all storage, comparison, and arithmetic happen in one consistent frame, and the messy per-user, per-region complexity is confined to the thin presentation edge. Two events stored in UTC can always be compared and ordered correctly regardless of where they occurred.
Benchmark note: "Store UTC, convert at the edges" is the closest thing to a universal rule in date/time handling, and most date bugs trace to violating it — storing DateTime.Now (server-local) instead of DateTime.UtcNow means your data's meaning depends on where the server happened to run, which breaks the moment you change regions or run multiple regions. The one caveat: UTC is correct for past/instantaneous events ("when did this happen"). It is not automatically correct for future events tied to a wall-clock time in a place (see Way 4) — "3 PM Berlin next March" can shift relative to UTC if DST rules change between now and then. For the vast majority of timestamps (created-at, logged-at, occurred-at), store UTC and convert only at display/input.
Way 2: Use the Right Type (DateTimeOffset, DateOnly, TimeOnly)
Best used for: Choosing the correct .NET type for each kind of temporal value — an absolute instant, a date with no time, or a time with no date — so the type itself prevents whole classes of error.
The concept: .NET offers several temporal types, and using the right one encodes meaning and prevents bugs. DateTimeOffset represents an unambiguous instant (includes the offset from UTC). DateOnly (.NET 6+) is a calendar date with no time or zone — ideal for birthdays, due dates. TimeOnly is a time of day with no date — opening hours, alarm times. Plain DateTime is ambiguous (its Kind — Utc/Local/Unspecified — is a frequent source of bugs) and should be used carefully.
// DateTimeOffset — an unambiguous instant; preferred for timestamps DateTimeOffset placedAt = DateTimeOffset.UtcNow; // carries the offset explicitly // Two DateTimeOffsets always compare correctly — no Kind ambiguity // DateOnly — a calendar date with NO time and NO zone (.NET 6+) DateOnly birthDate = new(1990, 5, 15); // a birthday is a DATE, not an instant DateOnly dueDate = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(30); // Avoids the classic bug of a "date" carrying a midnight time that shifts across zones // TimeOnly — a time of day with NO date (.NET 6+) TimeOnly opensAt = new(9, 0); // shop opens at 09:00 — not tied to any date TimeOnly closesAt = new(17, 30); // Plain DateTime — ambiguous: ALWAYS set Kind explicitly if you must use it var utc = DateTime.UtcNow; // Kind = Utc ✓ var bad = new DateTime(2024, 6, 1, 15, 0, 0); // Kind = Unspecified — ambiguous! ✗ var ok = new DateTime(2024, 6, 1, 15, 0, 0, DateTimeKind.Utc); // explicit ✓
Why it works: The type carries the meaning. DateTimeOffset cannot be ambiguous about which instant it represents because it includes the offset. DateOnly for a birthday means there is no time component to accidentally shift across zones (the classic "birthday shows as the day before for some users" bug, caused by storing midnight-local and converting). Picking the type that matches the concept makes the wrong usage impossible rather than merely discouraged.
Benchmark note: Type choice prevents bugs structurally. The most common is treating a pure date as a timestamp: storing a birthday as DateTime at midnight, then converting time zones, can roll it to the previous or next day for users in different offsets — DateOnly eliminates this because there is no time to shift. DateTimeOffset versus DateTime matters because DateTime.Kind is a silent trap: an Unspecified-kind DateTime gets interpreted differently by different APIs (some assume local, some UTC), causing offset bugs. EF Core and both major databases map these types well (timestamptz/datetimeoffset for instants, date for DateOnly, time for TimeOnly). Default to DateTimeOffset for instants and DateOnly/TimeOnly for date-or-time-only values; reserve plain DateTime for when you must, always with an explicit Kind.
Way 3: IANA Time Zones & Correct Conversion
Best used for: Converting between UTC and user-local time reliably across platforms, using proper time-zone identifiers that account for daylight-saving rules and their historical changes.
The concept: A time zone is not a fixed offset — it is a set of rules (including when DST starts/ends) that change over history and by region. You must convert using a real time-zone database keyed by IANA identifiers ("Europe/Berlin", "America/New_York"), not by hardcoding an offset like "+1". .NET 6+ accepts IANA IDs cross-platform, and TimeZoneInfo applies the correct DST rules for the specific date being converted.
// Use IANA time zone IDs — work cross-platform on .NET 6+ (Windows, Linux, macOS)
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
// Conversion applies the correct DST rules FOR THAT DATE automatically
var summerUtc = new DateTime(2024, 7, 1, 12, 0, 0, DateTimeKind.Utc);
var winterUtc = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc);
var summerLocal = TimeZoneInfo.ConvertTimeFromUtc(summerUtc, berlin); // 14:00 (CEST, +2)
var winterLocal = TimeZoneInfo.ConvertTimeFromUtc(winterUtc, berlin); // 13:00 (CET, +1)
// Same zone, different offset depending on DST — handled correctly because we used the rules,
// NOT a hardcoded offset.
// NEVER do this — a hardcoded offset is wrong half the year:
// var wrong = utc.AddHours(1); // breaks during summer time
// For heavy date/time work, the NodaTime library models these concepts even more rigorously
// (Instant, ZonedDateTime, LocalDate, etc.) and removes DateTime's ambiguities entirely.
Why it works: Converting through a time-zone database means the correct offset is chosen based on the actual rules in effect on the specific date — including DST, and including historical rule changes (zones have altered their DST policies over the years). A hardcoded offset is right only part of the year and wrong during the other part; rule-based conversion is always correct because it consults what the offset actually was (or will be) at that moment.
Benchmark note: Hardcoding offsets is a top source of time-zone bugs — code that adds a fixed number of hours works in testing (done in one season) and breaks six months later when DST shifts the offset, a bug that is invisible until the calendar reaches it. Always convert through TimeZoneInfo with an IANA ID. The cross-platform note matters: historically Windows used different zone IDs ("W. Europe Standard Time") than Linux/IANA ("Europe/Berlin"); .NET 6+ accepts IANA IDs on all platforms, so standardise on IANA. For applications where date/time correctness is critical or complex (scheduling, finance, calendars), NodaTime is widely recommended — it replaces .NET's ambiguous DateTime with a set of unambiguous types that make time-zone mistakes far harder to make. Store the user's IANA zone ID with their profile so you can always convert correctly for them.
Way 4: Future Events & Wall-Clock Scheduling
Best used for: Events scheduled for a future local time — "the meeting is at 3 PM Berlin time next March", "run this report at 9 AM local every day" — where storing only UTC can become wrong if time-zone rules change before the event.
The concept: For future events tied to a wall-clock time in a specific place, the user means a local time, and the correct UTC equivalent depends on DST rules that might change between now and then. So you store the local time plus the zone (not just the pre-computed UTC), and resolve to UTC at the time it matters. This preserves the user's actual intent ("3 PM their time") even if the rules shift.
// PAST event — UTC is correct and sufficient (the moment already happened)
var loggedAt = DateTimeOffset.UtcNow; // store UTC, done
// FUTURE wall-clock event — store the LOCAL time AND the zone, not just UTC
public class ScheduledMeeting
{
public DateTime LocalDateTime { get; set; } // 2025-03-30 15:00 (what the user meant)
public string TimeZoneId { get; set; } // "Europe/Berlin"
// Resolve to UTC when needed — using the rules in effect, computed fresh:
public DateTimeOffset ResolveToUtc()
{
var tz = TimeZoneInfo.FindSystemTimeZoneById(TimeZoneId);
return new DateTimeOffset(LocalDateTime,
tz.GetUtcOffset(LocalDateTime)); // offset for THAT local date, per current rules
}
}
// If the user means "3 PM Berlin", they mean 3 PM Berlin regardless of whether DST rules
// change before March. Storing pre-computed UTC would lock in a possibly-stale offset.
// RECURRING jobs — "every day at 9 AM local" must re-resolve each day, because the
// UTC time of "9 AM local" shifts by an hour across the DST transition:
public DateTimeOffset NextNineAmLocal(string tzId)
{
var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId);
var nowLocal = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, tz);
var next = nowLocal.Date.AddDays(nowLocal.Hour >= 9 ? 1 : 0).AddHours(9);
return new DateTimeOffset(next.DateTime, tz.GetUtcOffset(next.DateTime));
}
Why it works: A future local time expresses an intent ("3 PM where I am") that should survive rule changes. By storing the local time and the zone and resolving to UTC at execution, you always honour what the user meant, computing the correct UTC using whatever rules are current at resolution time. For recurring local-time jobs, re-resolving each occurrence means "9 AM local" stays 9 AM local across DST transitions, rather than drifting by an hour.
Benchmark note: This is the important exception to "always store UTC", and it trips up even experienced developers. For anything already happened, UTC is right. For a future wall-clock commitment, pre-computing and storing UTC can become wrong, because governments change DST rules (and even base offsets) with surprising frequency, and a meeting stored as a fixed UTC moment would then fire at the wrong local time. Storing local-time-plus-zone preserves intent. The recurring-job version is a classic subtle bug: a job scheduled in UTC for "07:00 UTC = 9 AM Berlin winter" silently becomes 8 AM Berlin in summer — re-resolving the local target each run fixes it. The practical rule: past/instant → store UTC; future local commitment → store local + zone and resolve late.
Way 5: "Day" Boundaries, Ranges & Reporting
Best used for: Reports, aggregations, and filters over date ranges — "today's sales", "this month's signups", "events between two dates" — where the definition of a day boundary depends on the time zone and getting it wrong silently miscounts.
The concept: "Today" is not a UTC concept — it is a local one, and it spans a different absolute window for users in different zones. When a report says "yesterday's orders", you must define yesterday's boundaries in the relevant time zone, convert those boundaries to UTC, and filter the UTC-stored data against them. Off-by-a-few-hours errors at the boundary silently include or exclude records near midnight.
// WRONG — "today" using UTC boundaries when the business operates in a local zone
var wrongStart = DateTime.UtcNow.Date; // midnight UTC — not midnight where the business is
var todayWrong = await _db.Orders.Where(o => o.PlacedAt >= wrongStart).ToListAsync(ct);
// For a Berlin business, this includes the wrong slice around midnight.
// RIGHT — define the day in the BUSINESS's time zone, convert boundaries to UTC, then filter
public async Task<List<Order>> GetTodaysOrdersAsync(string businessTimeZoneId, CancellationToken ct)
{
var tz = TimeZoneInfo.FindSystemTimeZoneById(businessTimeZoneId);
var nowLocal = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, tz);
// Local midnight today and tomorrow
var localStart = nowLocal.Date;
var localEnd = localStart.AddDays(1);
// Convert those LOCAL boundaries to UTC for querying UTC-stored data
var utcStart = TimeZoneInfo.ConvertTimeToUtc(localStart, tz);
var utcEnd = TimeZoneInfo.ConvertTimeToUtc(localEnd, tz);
// Half-open range [start, end) — avoids both missing and double-counting the boundary
return await _db.Orders
.Where(o => o.PlacedAt >= utcStart && o.PlacedAt < utcEnd)
.ToListAsync(ct);
}
Why it works: By defining the day's boundaries in the time zone that the report is about (the business's, or the user's) and converting those boundaries to UTC before querying, the filter captures exactly the records that fall within that local day — no more, no less. The half-open range [start, end) (inclusive start, exclusive end) is the correct way to express a range so that a record at exactly midnight is counted once, in the right day, never missed or double-counted.
Benchmark note: Date-range and "today" bugs are insidious because they are almost right — they miscount only the records near the boundary, so totals are slightly off in ways that are easy to miss and hard to trace. Defining boundaries in UTC when the business thinks in local time shifts every "day" by the offset (hours of orders attributed to the wrong day). The half-open interval convention (>= start && < end) matters too: using <= end with an inclusive end double-counts or misattributes the boundary instant. For reporting specifically, also decide whose day matters — the business's fixed zone (usual for internal reports) or each user's zone (for user-facing "your activity today") — and be consistent. Month and year boundaries have the same issue, compounded by varying month lengths. When in doubt, compute boundaries explicitly in the target zone and convert to UTC; never assume UTC midnight equals local midnight.
Choosing the right approach (these are layers of one strategy)
- Storing any timestamp → Store UTC, convert at the edges (Way 1)
- Picking the data type for a temporal value → DateTimeOffset / DateOnly / TimeOnly (Way 2)
- Converting between UTC and local → IANA zones + TimeZoneInfo (Way 3)
- Scheduling future local-time events → Store local + zone, resolve late (Way 4)
- Reports and date-range filters → Local day boundaries → UTC, half-open ranges (Way 5)
The coherent strategy: store instants in UTC using DateTimeOffset (Way 1, Way 2), and treat local time strictly as a presentation concern converted at the edges via IANA time zones and TimeZoneInfo (Way 3) — never with hardcoded offsets. Use DateOnly/TimeOnly for values that are genuinely date-only or time-only so they cannot shift across zones. Recognise the two exceptions where UTC alone is insufficient: future wall-clock commitments, which need local-time-plus-zone stored and resolved late (Way 4), and date-range reporting, which needs day boundaries defined in the relevant zone then converted to UTC with half-open intervals (Way 5). For applications where this is core or complex, adopt NodaTime to make the ambiguities structurally impossible. The unifying truth: a moment in time is absolute (store it absolutely, in UTC), but how humans name and bound time is local (handle that only at the edges) — and the bugs come from blurring those two, which is why time-zone defects are so often invisible until a user in the wrong place, or the calendar on the wrong date, reveals them.
Note: this is an informational engineering overview. Time-zone and DST rules change over time and are maintained in the underlying OS/runtime time-zone database; keep systems updated. For complex date/time domains, evaluate NodaTime. Always test boundary and DST-transition cases explicitly.
More from the playbooks
Mapping & Transforming Data (Entity to DTO)
You accidentally serialized a password hash to the API
Validating Input
Bad data reached your database and corrupted state
Configuration & Secrets Management
A database password just got committed to Git