C# reference · Dates & times

C# Date & Time Handling

The C#/.NET answer to “how do I do dates?” in one place — the types and which to reach for, the format specifiers, parsing, time zones, UTC, arithmetic, and the foot-guns.

Which type do I reach for?

DateTimeOffset

An absolute moment in time (a timestamp, a log entry, “when it happened”). Carries an offset from UTC, so it's unambiguous. The default for an instant.

DateOnly / TimeOnly

A calendar date with no time (a birthday, an invoice date) or a time with no date (a store's opening time). .NET 6+. No zone, no Kind confusion.

DateTime

The classic type — but it silently carries a Kind (Utc/Local/Unspecified) that equality ignores. Fine when you control the Kind; the source of most date bugs when you don't.

TimeSpan

A duration or elapsed interval (“3 days, 4 hours”), and what you get when you subtract two dates. Not a time-of-day type — use TimeOnly for that.

Format specifiers

The most-searched date answer: “give me the format string.” Pass a specifier to ToString(…) or an interpolation ($"{dt:yyyy-MM-dd}"). Two kinds: standard specifiers are single letters that pick a culture-defined pattern; custom tokens spell out a pattern exactly. The load-bearing traps live in the case: MM is month but mm is minute; HH is 24-hour but hh is 12-hour.

0 of 0 rows

Example output is the .NET docs' canonical instant — Monday, June 15, 2009 1:45:30.617 PM at offset -07:00, formatted with the en-US culture. Standard-specifier output is culture-sensitive; custom-token output mostly isn't.

Standard specifiers

Spec Meaning Example (en-US)
dShort date6/15/2009
DLong dateMonday, June 15, 2009
fLong date + short timeMonday, June 15, 2009 1:45 PM
FLong date + long timeMonday, June 15, 2009 1:45:30 PM
gGeneral — short date + short time6/15/2009 1:45 PM
GGeneral — short date + long time6/15/2009 1:45:30 PM
M / mMonth + dayJune 15
O / oRound-trip (ISO 8601, preserves offset/Kind) — the one to persist2009-06-15T13:45:30.6170000-07:00
R / rRFC 1123 (HTTP headers) — converts to UTC, appends “GMT”Mon, 15 Jun 2009 20:45:30 GMT
sSortable (ISO 8601, no offset)2009-06-15T13:45:30
tShort time1:45 PM
TLong time (with seconds)1:45:30 PM
uUniversal sortable — converts to UTC, appends “Z”2009-06-15 20:45:30Z
UUniversal full — converts to UTC, long form (DateTime only)Monday, June 15, 2009 8:45:30 PM
Y / yYear + monthJune 2009

Mind the UTC conversion. R and u convert a DateTimeOffset to UTC before formatting — that’s why the examples above read 20:45, not 13:45. But they do not convert a bare DateTime: call .ToUniversalTime() first, or you’ll stamp a local time with a “GMT”/“Z” that lies.

Custom tokens

Token Meaning Example
yyyyYear, four digits2009
yyYear, two digits09
MMMMMonth, full nameJune
MMMMonth, abbreviated nameJun
MMMonth number, 2 digits — month, not minute06
MMonth number, no leading zero6
ddddDay of week, full nameMonday
dddDay of week, abbreviatedMon
ddDay of month, 2 digits15
dDay of month, no leading zero15
HHHour, 24-hour clock, 2 digits13
HHour, 24-hour clock13
hhHour, 12-hour clock, 2 digits01
hHour, 12-hour clock1
mmMinutes, 2 digits — minute, not month45
mMinutes, no leading zero45
ssSeconds, 2 digits30
sSeconds, no leading zero30
fffFractional seconds, 3 digits (milliseconds)617
FFFFractional seconds, trims trailing zeros617
ttAM/PM designatorPM
zzzOffset from UTC, hours + minutes-07:00
zzOffset from UTC, hours (2 digits)-07
KKind — “Z” for UTC, the offset for Local, empty for Unspecified-07:00

Culture matters: ToString("d") is 6/15/2009 in en-US but 15/06/2009 in en-GB. For machine-readable output that never depends on the machine's locale, pass CultureInfo.InvariantCulture — or use the round-trip ("o") or sortable ("s") specifiers, which are invariant by design. Full grammar: standard and custom format strings.

JSON & API timestamps

That 2009-06-15T20:45:30.617Z shape in JSON payloads is ISO 8601 in UTC (the trailing Z, “Zulu,” is the +00:00 offset) — no single standard specifier prints it exactly.

Write — from a UTC value

// dtUtc: the instant, Kind = Utc
dtUtc.ToString(
    "yyyy-MM-ddTHH:mm:ss.fffZ",
    CultureInfo.InvariantCulture);
// 2009-06-15T20:45:30.617Z

The Z is a literal — the value must already be UTC.

Read — back to an instant

var dto = DateTimeOffset.Parse(
    "2009-06-15T20:45:30.617Z",
    CultureInfo.InvariantCulture);
// offset 00:00; TryParse for untrusted input

Parse into DateTimeOffset — no Kind to guess.

  • DateTime.Parse converts Z to local time unless you pass DateTimeStyles.RoundtripKind.
  • Round-trip "o" is closest, but writes seven fractional digits and prints Z only for a Utc-Kind DateTime.
  • A DateTimeOffset serializes with its offset (+00:00), not Z.
  • System.Text.Json reads & writes this shape automatically for DateTimeOffset/DateTime properties.

The styles that control parsing (RoundtripKind, AssumeUniversal, AdjustToUniversal) are covered under parsing date and time strings.

Showing 0 of 0 answers

1 · The types & which to use

What are the date/time types, and which should I use?

Six BCL types cover almost everything. DateTimeOffset is a moment in time with an offset from UTC — the right default for a timestamp. DateTime is the older date-and-time type that carries a Kind flag instead of an offset. DateOnly and TimeOnly (.NET 6+) are a calendar date with no time and a time with no date. TimeSpan is a duration. TimeZoneInfo represents a zone and its DST rules.

DateTimeOffset when   = DateTimeOffset.UtcNow;      // an instant — preferred
DateOnly       dob    = new(1990, 5, 20);          // a wall date
TimeOnly       opens  = new(9, 0);                 // a wall time
TimeSpan       ttl    = TimeSpan.FromMinutes(30);   // a duration

Rule of thumb: an instant (log line, “created at”) → DateTimeOffset; a wall date/time with no zone (a birthday, a daily opening time) → DateOnly/TimeOnly; a durationTimeSpan. Reach for a bare DateTime only when you're deliberately in UTC or you fully control its Kind. DateOnly/TimeOnly arrived in C# 10 / .NET 6.

Source: .NET — Choose between DateTime, DateTimeOffset, DateOnly, and TimeOnly.

What is the DateTime.Kind problem, and why prefer DateTimeOffset?

A DateTime carries a DateTimeKindUtc, Local, or Unspecified — but the value looks the same either way, and the default from most sources is Unspecified. So 2009-06-15 13:45 could mean UTC, local time in some zone, or “we don't know,” and nothing in the value tells you which. Worse, equality ignores Kind (see the comparison section), so a UTC value and a local value with the same digits compare equal. DateTimeOffset fixes this by pinning an explicit offset from UTC, so the value is an unambiguous point on the timeline.

DateTime a = new(2009, 6, 15, 13, 45, 0);   // Kind = Unspecified
// Is that UTC? Local? Nobody knows. This is the footgun.

DateTimeOffset b = new(2009, 6, 15, 13, 45, 0, TimeSpan.FromHours(-7));
// Unambiguous: 13:45 at UTC-7 == 20:45 UTC.

Store and pass instants as DateTimeOffset (or as UTC DateTime if you're disciplined about it). Keep DateTime for wall-clock values you're about to hand to a time zone — but DateOnly/TimeOnly are usually the better fit for those now.

Source: DateTimeKind / choosing guidance.

Are the date/time types mutable? Are they thread-safe?

All of them are immutable value types (structs). Every “mutating” method — AddDays, ToUniversalTime, AddMonths — returns a new value and leaves the original untouched. That makes them safe to share across threads freely; there's no formatter-state to corrupt (contrast Java's old thread-unsafe SimpleDateFormat). The corollary is a common beginner bug: calling dt.AddDays(1) and ignoring the return value does nothing.

var d = new DateTime(2009, 6, 15);
d.AddDays(1);            // BUG: result discarded, d is unchanged
d = d.AddDays(1);        // correct: reassign the new value

Source: DateTime (“this type is immutable”).

2 · Getting “now”

Now vs UtcNow vs Today — which do I want?

For anything you'll store, compare, or send over a wire, use a UTC reading: DateTimeOffset.UtcNow (best) or DateTime.UtcNow. DateTime.Now gives local time with Kind = Local — fine for display, a trap for storage, because the server's zone leaks in and DST makes it non-monotonic. DateTime.Today is midnight local today (a DateTime at 00:00); for a pure date prefer DateOnly.FromDateTime(DateTime.Now).

DateTimeOffset.UtcNow;   // instant, unambiguous — store this
DateTime.UtcNow;         // instant, Kind = Utc
DateTime.Now;            // local wall clock, Kind = Local — display only
DateOnly.FromDateTime(DateTime.Now);  // today's date, no time

Source: DateTime.UtcNow / DateTimeOffset.UtcNow.

How do I make “now” testable? (TimeProvider)

Calling DateTime.Now directly inside logic makes that logic impossible to test at a fixed instant. Since .NET 8, inject TimeProvider — an abstract clock — instead. Production passes TimeProvider.System; a test passes a fake that returns a frozen time (the FakeTimeProvider in Microsoft.Extensions.TimeProvider.Testing). It also abstracts timers, so you can advance time deterministically in tests.

class TokenService(TimeProvider clock)
{
    public bool IsExpired(DateTimeOffset exp) => clock.GetUtcNow() > exp;
}
// prod: new TokenService(TimeProvider.System)
// test: new TokenService(fakeClockFrozenAtNoon)

TimeProvider arrived in .NET 8 (Nov 2023). Before it, the pattern was a hand-rolled IClock interface.

Source: TimeProvider.

3 · Formatting

How do I format a date to a string?

Pass a specifier (see the table above) to ToString, or use it in an interpolated string after a colon. The one decision that bites: culture. With no culture argument you get the current thread's culture, so the same code prints differently on different machines. For output a machine will read back, pass CultureInfo.InvariantCulture.

var dt = new DateTime(2009, 6, 15, 13, 45, 30);

dt.ToString("yyyy-MM-dd HH:mm");          // 2009-06-15 13:45
$"{dt:yyyy-MM-dd}";                       // 2009-06-15  (interpolation)
dt.ToString("d", CultureInfo.InvariantCulture); // 06/15/2009
dt.ToString("o");                          // round-trip ISO 8601 — persist this

For machine-readable timestamps, prefer "o" (round-trip) or an explicit invariant pattern over locale-dependent output.

Source: Formatting types in .NET.

4 · Parsing

Parse vs TryParse vs ParseExact — which and when?

Use TryParse for anything that could be malformed (user input, external data): it returns a bool instead of throwing. Use ParseExact/TryParseExact when the input has a known fixed layout and you want to reject anything else. Plain Parse is the throw-on-failure flexible parser. Always parse machine data with CultureInfo.InvariantCulture so "06/15/2009" isn't misread as day-first — the classic month/day parsing bug.

if (DateTime.TryParse(input, CultureInfo.InvariantCulture,
        DateTimeStyles.None, out var dt)) { /* dt is valid */ }

// Strict, exact layout — rejects anything else:
DateTime.TryParseExact("2009-06-15", "yyyy-MM-dd",
    CultureInfo.InvariantCulture, DateTimeStyles.None, out var d);

// Round-trip an "o"/"s" string back losslessly:
var back = DateTimeOffset.Parse("2009-06-15T13:45:30.617-07:00");

DateTimeStyles controls whitespace, assumed-UTC-vs-local, and whether to keep the offset (RoundtripKind / AssumeUniversal). Parsing an "o"-formatted string with DateTimeOffset preserves the offset exactly.

Source: Parsing date and time strings in .NET.

5 · Time zones

How do I convert a time between zones?

Get a TimeZoneInfo by id with FindSystemTimeZoneById, then convert with TimeZoneInfo.ConvertTime. Prefer working from a DateTimeOffset so the source instant is unambiguous. The conversion applies that zone's DST rules for the given date automatically.

var pacific = TimeZoneInfo.FindSystemTimeZoneById("America/Los_Angeles");
var utc     = DateTimeOffset.UtcNow;
var local   = TimeZoneInfo.ConvertTime(utc, pacific);  // applies DST for that date

Source: TimeZoneInfo.ConvertTime.

Why does my time-zone id work on one OS but not another? (Windows vs IANA)

The cross-platform tripwire. Historically Windows used its own zone ids ("Pacific Standard Time") while Linux/macOS use IANA ids ("America/Los_Angeles"), so an id hard-coded for one OS threw TimeZoneNotFoundException on the other. Since .NET 6, the runtime uses ICU and TimeZoneInfo accepts both forms on both platforms and can translate between them with TryConvertIanaIdToWindowsId / TryConvertWindowsIdToIanaId. On older runtimes you needed a mapping library.

// .NET 6+: both of these resolve on Windows AND Linux/macOS
TimeZoneInfo.FindSystemTimeZoneById("America/Los_Angeles");
TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time");

TimeZoneInfo.TryConvertWindowsIdToIanaId("Pacific Standard Time", out var iana);
// iana == "America/Los_Angeles"

Prefer IANA ids for new code; they're the cross-platform standard.

Source: Time zones overview / ID conversion helpers.

How do I handle DST edge cases (ambiguous / invalid local times)?

Around a DST transition a local wall-clock time can be ambiguous (the “fall back” hour happens twice) or invalid (the “spring forward” hour never happens). TimeZoneInfo exposes IsAmbiguousTime and IsInvalidTime so you can detect and decide, rather than getting a silently wrong instant.

var tz = TimeZoneInfo.FindSystemTimeZoneById("America/Los_Angeles");
var t  = new DateTime(2009, 11, 1, 1, 30, 0);  // falls in the repeated hour
tz.IsAmbiguousTime(t);   // true — occurs twice that day
tz.IsInvalidTime(t);     // false here; true for a skipped spring-forward time

This is exactly the ambiguity DateTimeOffset avoids for stored instants — the offset disambiguates which of the two occurrences you mean.

Source: IsAmbiguousTime / IsInvalidTime.

6 · Converting to/from UTC

How do I convert to and from UTC without losing information?

ToUniversalTime() and ToLocalTime() convert — but for a DateTime they rely on Kind, and a value with Kind = Unspecified gets assumed to be local, which is where round-trips lose information. SpecifyKind stamps a Kind without changing the digits (use it when you know where a value came from). The clean answer: hold instants as DateTimeOffset, whose .UtcDateTime and .ToOffset() convert with no Kind guessing.

var utc   = DateTime.SpecifyKind(raw, DateTimeKind.Utc); // stamp, don't shift
var local = utc.ToLocalTime();                          // now correct

// Ambiguity-free path:
DateTimeOffset dto = DateTimeOffset.Now;
DateTime asUtc = dto.UtcDateTime;                        // no Kind guessing

Round-tripping a bare DateTime through a string can drop the Kind unless you use the "o" specifier with DateTimeStyles.RoundtripKind.

Source: DateTime.SpecifyKind / ToUniversalTime.

7 · Arithmetic

How do I add and subtract time?

Use the Add* methods (each returns a new value) or add/subtract a TimeSpan with the operators. Two behaviors surprise people. AddMonths clamps to the end of the shorter month — Jan 31 + 1 month is Feb 28, not an error, and it isn't reversible. And a calendar day isn't always 24 hours across a DST boundary, so adding TimeSpan.FromDays(1) to a zone-aware value can land on a different wall-clock time.

var d = new DateTime(2009, 1, 31);
d.AddMonths(1);          // 2009-02-28  (clamped; NOT reversible)
d.AddDays(2.5);          // fractional units are fine
d + TimeSpan.FromHours(6); // operator form

Source: DateTime.AddMonths.

8 · Differences between two dates

How do I get the difference between two dates?

Subtract them — the result is a TimeSpan. Read whole units with Days/Hours (the component parts) or fractional totals with TotalDays/TotalHours (the whole span expressed in that unit). The two are easy to mix up: for 36 hours, Days is 1 but TotalDays is 1.5.

TimeSpan span = end - start;
span.Days;        // whole days (component)
span.TotalDays;   // e.g. 1.5 — the whole span as days
span.TotalHours;  // e.g. 36.0

TimeSpan has no “months” or “years” — those aren't fixed lengths. For “whole months/years between,” compute from the components (see the age recipe) or use Noda Time's Period.

Source: TimeSpan.

9 · Comparison & equality

Why do two “equal” times compare unequal — or unequal ones compare equal?

Compare with ==, <, or CompareTo. The trap: DateTime equality compares ticks and ignores Kind. So a UTC value and a local value with the same digits compare equal even though they're different instants; and 13:45 UTC vs 13:45 UTC-7 (which are seven hours apart) also compare equal because the digits match. DateTimeOffset equality instead compares the actual instant, so two values with different offsets but the same moment are equal.

var u = new DateTime(2009, 6, 15, 13, 45, 0, DateTimeKind.Utc);
var l = new DateTime(2009, 6, 15, 13, 45, 0, DateTimeKind.Local);
u == l;                    // TRUE — Kind ignored (the footgun)

DateTimeOffset a = new(2009,6,15,13,45,0, TimeSpan.Zero);
DateTimeOffset b = new(2009,6,15, 6,45,0, TimeSpan.FromHours(-7));
a == b;                    // TRUE — same instant, compared correctly

Convert to a common Kind (or use DateTimeOffset) before comparing DateTime values.

Source: DateTime equality / DateTimeOffset.Equals.

10 · Min / max & “no value”

What are the min/max values, and how do I represent “no date”?

The range is DateTime.MinValue (0001-01-01) to DateTime.MaxValue (9999-12-31 23:59:59). A common antipattern is using MinValue as a stand-in for “not set” — it's indistinguishable from a real minimum date, and because default(DateTime) is MinValue, an unset field silently looks like a valid ancient date. Represent “maybe no date” with a nullable DateTime? instead, so “absent” is a real null.

DateTime.MinValue;     // 0001-01-01 — also == default(DateTime)
DateTime.MaxValue;     // 9999-12-31 23:59:59

DateTime? completed = null;  // "not completed" — the right way
if (completed is { } when) { /* it has a value: 'when' */ }

Source: DateTime.MinValue / nullable value types.

11 · Unix / epoch time

How do I convert to/from a Unix timestamp? Is there a 2038 problem?

DateTimeOffset has the built-in conversions: FromUnixTimeSeconds/ToUnixTimeSeconds and the millisecond variants. No manual epoch math needed. And there's no year-2038 problem in .NET — the Unix methods use a 64-bit count, and internally a DateTime is 64-bit ticks (100-nanosecond units since 0001-01-01), so the range runs to the year 9999.

long secs = DateTimeOffset.UtcNow.ToUnixTimeSeconds();      // e.g. 1244497530
var dto  = DateTimeOffset.FromUnixTimeSeconds(secs);        // back to a DateTimeOffset
DateTimeOffset.FromUnixTimeMilliseconds(1244497530617);   // ms variant

A “tick” is 100 ns, so TimeSpan.TicksPerSecond is 10,000,000. That resolution is finer than most system clocks actually provide (see the next section).

Source: DateTimeOffset.FromUnixTimeSeconds.

12 · Precision & measuring elapsed time

How do I time an operation? (Why not DateTime.Now?)

Use Stopwatch, never DateTime.Now. The wall clock is not monotonic — NTP sync, DST, or a manual clock change can move it backward, giving you a negative or wildly wrong elapsed time. Stopwatch is backed by a high-resolution monotonic counter built for exactly this. DateTime.Now also has coarse resolution (often ~15 ms), so it can't measure short operations at all.

var sw = Stopwatch.StartNew();
DoWork();
sw.Stop();
sw.Elapsed;               // a TimeSpan — monotonic, high-resolution
sw.ElapsedMilliseconds;   // e.g. 42

Rule: DateTime/DateTimeOffset answer “what time is it?”; Stopwatch answers “how long did that take?”

Source: Stopwatch.

13 · Serialization

How do dates round-trip through JSON (and databases)?

System.Text.Json writes DateTime/DateTimeOffset as ISO 8601 (the round-trip "o" shape) by default, which is the good outcome — a DateTimeOffset round-trips losslessly. The trap is the same Kind problem: a DateTime serialized without offset information can come back with a different Kind than it went out with, so prefer DateTimeOffset (or UTC) for anything you serialize. Newtonsoft.Json historically defaulted to converting to local time on read — a classic surprise — so check its DateTimeZoneHandling if you use it.

JsonSerializer.Serialize(DateTimeOffset.UtcNow);
// "2009-06-15T20:45:30.617+00:00"  — ISO 8601, round-trips cleanly

On the database side, map an instant to datetimeoffset (SQL Server) rather than datetime2 when you need the offset; EF Core preserves the CLR type you map to. This is a mention-and-link, not a deep dive.

Source: DateTime and DateTimeOffset support in System.Text.Json.

14 · Common recipes

Start of day, end of month, next Monday, age, is-weekend, is-leap-year?

The everyday one-liners, using built-in helpers where they exist (DateTime.DaysInMonth, DateTime.IsLeapYear, Date, DayOfWeek).

var startOfDay = dt.Date;                              // midnight (time stripped)
var daysInMonth = DateTime.DaysInMonth(2009, 2);        // 28
var endOfMonth = new DateTime(y, m, DateTime.DaysInMonth(y, m));
bool weekend = dt.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
bool leap = DateTime.IsLeapYear(2024);                  // true

// Next Monday (on or after tomorrow):
int delta = ((int)DayOfWeek.Monday - (int)dt.DayOfWeek + 7) % 7;
var nextMon = dt.AddDays(delta == 0 ? 7 : delta);

// Age in whole years from a birthdate:
int age = today.Year - dob.Year;
if (dob.Date > today.AddYears(-age)) age--;             // not had birthday yet

DateOnly (.NET 6+) is the better carrier for the date-only recipes — it has no time component to strip.

Source: DateTime.DaysInMonth / IsLeapYear.

15 · Noda Time (the external library)

What is Noda Time, and when should I reach for it?

Noda Time is a widely-used open-source date/time library (from Jon Skeet) that replaces the BCL's ambiguous model with distinct, purpose-named types: Instant (a moment on the timeline), LocalDate / LocalTime / LocalDateTime (wall values with no zone), ZonedDateTime (a local value plus a zone), Duration (elapsed time), Period (calendar amounts like “3 months”), and DateTimeZone. Because each concept is its own type, whole classes of DateTime.Kind bugs simply can't compile.

Instant now = SystemClock.Instance.GetCurrentInstant();  // unambiguous
DateTimeZone la = DateTimeZoneProviders.Tzdb["America/Los_Angeles"];
ZonedDateTime local = now.InZone(la);
Period p = Period.Between(start, end);                     // real months/years

When to use it: apps where correct time-zone and calendar handling is central (scheduling, calendaring, billing across zones), or where you want the type system to enforce the instant-vs-wall-time distinction. For straightforward UTC timestamps, the BCL's DateTimeOffset is enough — Noda Time earns its dependency when the domain is genuinely temporal. It's a recommendation with tradeoffs, not a blanket “always use this.”

Source: Noda Time user guide.

The foot-guns, in one place

  • DateTime.Kind is silently Unspecified from most sources, and equality ignores it — prefer DateTimeOffset.
  • MM is month, mm is minute; HH is 24-hour, hh is 12-hour. The case is the meaning.
  • Parse machine data with CultureInfo.InvariantCulture, or "06/15" may be read day-first.
  • AddMonths clamps Jan 31 + 1 month to Feb 28, and it isn't reversible.
  • Windows zone ids ("Pacific Standard Time") vs IANA ("America/Los_Angeles") — .NET 6+ accepts both; older runtimes don't.
  • DateTime.MinValue as “no value” is indistinguishable from a real date — use DateTime?.
  • == on DateTime ignores Kind, so a UTC and a local value with the same digits compare equal.
  • Time an operation with Stopwatch, never DateTime.Now — the wall clock isn't monotonic.

Every answer links its primary source inline — the official .NET date, time, and time-zone documentation and the API references for the types it covers. The standard and custom format-string pages back the specifier tables, and the external-library section links the Noda Time user guide. Feature-arrival details (DateOnly/TimeOnly, TimeProvider) cross-link to the C# version reference and the .NET version reference. Last updated July 2026.

Mungomash LLC · More on C#