Part 6 of 8 in the series Time in Software, Done Right
You've made it through the conceptual articles. You understand the difference between instants and local times, between global and local events, between storing intent and storing math.
Now let's make it real in .NET.
The BCL has improved significantly — DateOnly and TimeOnly (since .NET 6) are solid types for dates and times. But for timezone-aware scheduling — meetings, deadlines, appointments that need to survive DST changes — you'll want NodaTime. It gives you the types the BCL is still missing.
This article shows you how to use NodaTime to model time correctly, store it properly, and avoid the traps we've discussed throughout this series.
Why DateTime Falls Short (and What the BCL Fixed)
DateTime in .NET is a single type that tries to represent multiple concepts:
var a = DateTime.Now; // Local time on this machine
var b = DateTime.UtcNow; // UTC instant
var c = new DateTime(2026, 6, 5, 10, 0, 0); // Is this local? UTC? Unspecified?
The problem is the Kind property:
-
DateTimeKind.Local— local to this machine (not a specific timezone) -
DateTimeKind.Utc— a UTC instant -
DateTimeKind.Unspecified— could be anything
When you create new DateTime(2026, 6, 5, 10, 0, 0), the Kind is Unspecified. Is that 10:00 in Vienna? 10:00 in London? 10:00 UTC? The type doesn't know, and neither does your code.
The BCL Got Better: DateOnly and TimeOnly
.NET 6 added two types that address part of this problem:
DateOnly birthday = new DateOnly(1990, 3, 15); // Just a date, no time confusion
TimeOnly openingTime = new TimeOnly(9, 0); // Just a time, no date confusion
These are great! If you just need a date or just a time, use them. They're in the BCL, well-supported by EF Core, and do exactly what they say.
What the BCL Still Doesn't Have
But for the full picture — especially timezone-aware scheduling — the BCL still falls short:
- No
Instanttype (you useDateTimewithKind.UtcorDateTimeOffset) - No
LocalDateTimewith proper semantics (you useDateTimewithKind.Unspecified) - No
ZonedDateTimethat combines local time with a timezone - No first-class IANA timezone support (
TimeZoneInfouses Windows zones by default)
DateTimeOffset is better than DateTime — it includes an offset — but as we discussed in Article 4, an offset is a snapshot, not a meaning. +02:00 could be Vienna in summer, Berlin in summer, Cairo, or Johannesburg. You can't tell.
For simple cases: DateOnly, TimeOnly, DateTime, and DateTimeOffset are fine.
For timezone-aware scheduling: NodaTime gives you the right types for the right concepts.
The NodaTime Types You Need
Here's how NodaTime maps to the concepts we've covered (and their BCL equivalents where they exist):
| Concept | NodaTime Type | BCL Equivalent | Example |
|---|---|---|---|
| Physical moment | Instant |
DateTime (UTC) / DateTimeOffset
|
Log timestamp, token expiry |
| Calendar date | LocalDate |
DateOnly ✓ |
Birthday, holiday |
| Wall clock time | LocalTime |
TimeOnly ✓ |
"Opens at 09:00" |
| Date + time (no zone) | LocalDateTime |
DateTime (Unspecified) |
User's chosen meeting time |
| IANA timezone | DateTimeZone |
TimeZoneInfo (partial) |
Europe/Vienna |
| Full context | ZonedDateTime |
❌ None | Meeting at 10:00 Vienna |
| Snapshot with offset | OffsetDateTime |
DateTimeOffset |
What the clock showed at a moment |
The ✓ marks where the BCL type is a good choice. For DateOnly and TimeOnly, you can often skip NodaTime entirely.
The gap is ZonedDateTime — the combination of a local time and an IANA timezone that lets you handle DST correctly. That's where NodaTime shines.
Let's see each in action.
Instant: For Physical Moments
Use Instant when you're recording when something happened — independent of any human's calendar.
// Current moment
Instant now = SystemClock.Instance.GetCurrentInstant();
// From a Unix timestamp
Instant fromUnix = Instant.FromUnixTimeSeconds(1735689600);
// For logs, audits, event sourcing
public class AuditEntry
{
public Instant OccurredAt { get; init; }
public string Action { get; init; }
}
Instant is unambiguous. There's no timezone to confuse, no Kind property to check. It's just a point on the timeline.
LocalDate, LocalTime, LocalDateTime: For Human Concepts
These types represent calendar and clock values without a timezone attached.
// Just a date (NodaTime)
LocalDate birthday = new LocalDate(1990, 3, 15);
// Just a date (BCL - equally good!)
DateOnly birthdayBcl = new DateOnly(1990, 3, 15);
// Just a time (NodaTime)
LocalTime openingTime = new LocalTime(9, 0);
// Just a time (BCL - equally good!)
TimeOnly openingTimeBcl = new TimeOnly(9, 0);
// Date and time together (NodaTime)
LocalDateTime meetingTime = new LocalDateTime(2026, 6, 5, 10, 0);
For dates and times alone, use whichever you prefer — DateOnly/TimeOnly are in the BCL and work great with EF Core.
For date+time combinations that you'll later combine with a timezone, NodaTime's LocalDateTime is clearer because it's part of a coherent type system that includes ZonedDateTime.
A LocalDateTime of 2026-06-05T10:00 means "June 5th at 10:00" — but it doesn't yet specify where. That's intentional. You'll combine it with a timezone to get the full picture.
DateTimeZone: The Ruleset
A DateTimeZone represents an IANA timezone — not just an offset, but the complete ruleset including DST transitions and historical changes.
// Get a timezone by IANA ID
DateTimeZone vienna = DateTimeZoneProviders.Tzdb["Europe/Vienna"];
DateTimeZone london = DateTimeZoneProviders.Tzdb["Europe/London"];
// The provider gives you access to all IANA zones
IDateTimeZoneProvider tzdb = DateTimeZoneProviders.Tzdb;
DateTimeZoneProviders.Tzdb uses the IANA tz database, which is updated regularly with new rules. When you update NodaTime's tzdb data, your code automatically handles new DST rules.
ZonedDateTime: The Full Picture
ZonedDateTime combines a LocalDateTime with a DateTimeZone — giving you everything you need.
LocalDateTime local = new LocalDateTime(2026, 6, 5, 10, 0);
DateTimeZone zone = DateTimeZoneProviders.Tzdb["Europe/Vienna"];
// Combine them
ZonedDateTime zoned = local.InZoneLeniently(zone);
// Now you can get the instant
Instant instant = zoned.ToInstant();
// Or display in different zones
ZonedDateTime inLondon = instant.InZone(DateTimeZoneProviders.Tzdb["Europe/London"]);
Console.WriteLine(inNewYork.ToString("uuuu-MM-dd HH:mm x", CultureInfo.InvariantCulture));
// Output: 2026-06-05 04:00 -04
// (Requires: using System.Globalization;)
Why "Leniently"?
The InZoneLeniently method handles DST edge cases automatically:
- If the local time falls in a gap (doesn't exist), it shifts forward
- If the local time falls in an overlap (exists twice), it picks the earlier occurrence
For explicit control, NodaTime offers several options:
// Called on LocalDateTime
ZonedDateTime zoned = local.InZoneLeniently(zone); // Auto-resolve gaps/overlaps
ZonedDateTime zoned = local.InZoneStrictly(zone); // Throws if ambiguous
// Called on DateTimeZone (same behavior, different syntax)
ZonedDateTime zoned = zone.AtLeniently(local);
ZonedDateTime zoned = zone.AtStrictly(local);
// With custom resolver
ZonedDateTime zoned = local.InZone(zone, Resolvers.LenientResolver);
The Pattern: Store Intent, Derive Instant
Here's the core pattern from Article 4, implemented in NodaTime:
public class Appointment
{
// Source of truth: what the user chose
public LocalDateTime LocalStart { get; init; }
public string TimeZoneId { get; init; }
// Derived: for queries and sorting
public Instant InstantUtc { get; private set; }
public void RecalculateInstant()
{
var zone = DateTimeZoneProviders.Tzdb[TimeZoneId];
var zoned = LocalStart.InZoneLeniently(zone);
InstantUtc = zoned.ToInstant();
}
}
When timezone rules change, you call RecalculateInstant() on future appointments. Past appointments stay correct because IANA contains historical rules.
Real-World Examples
Example 1: Logging (Use Instant)
public class LogEntry
{
public Instant Timestamp { get; init; }
public string Level { get; init; }
public string Message { get; init; }
public static LogEntry Create(string level, string message)
{
return new LogEntry
{
Timestamp = SystemClock.Instance.GetCurrentInstant(),
Level = level,
Message = message
};
}
}
Example 2: Birthday (Use LocalDate)
public class Person
{
public string Name { get; init; }
public LocalDate DateOfBirth { get; init; }
public int GetAge(LocalDate today)
{
return Period.Between(DateOfBirth, today, PeriodUnits.Years).Years;
}
}
No timezone needed — birthdays are calendar concepts.
Example 3: Meeting (Use LocalDateTime + TimeZone)
public class Meeting
{
public string Title { get; init; }
public LocalDateTime LocalStart { get; init; }
public string TimeZoneId { get; init; }
public Instant InstantUtc { get; init; }
public static Meeting Create(string title, LocalDateTime localStart, string timeZoneId)
{
var zone = DateTimeZoneProviders.Tzdb[timeZoneId];
var instant = localStart.InZoneLeniently(zone).ToInstant();
return new Meeting
{
Title = title,
LocalStart = localStart,
TimeZoneId = timeZoneId,
InstantUtc = instant
};
}
// Display in any timezone
public string GetDisplayTime(DateTimeZone viewerZone)
{
var inViewerZone = InstantUtc.InZone(viewerZone);
// Note: uuuu is NodaTime's recommended year specifier (absolute year)
return inViewerZone.ToString("uuuu-MM-dd HH:mm", CultureInfo.InvariantCulture);
}
}
Example 4: Deadline (Use LocalDateTime + TimeZone)
public class Deadline
{
public LocalDateTime LocalDeadline { get; init; }
public string TimeZoneId { get; init; }
public Instant InstantUtc { get; init; }
public bool IsPastDeadline(Instant now)
{
return now > InstantUtc;
}
public Duration TimeRemaining(Instant now)
{
return InstantUtc - now;
}
}
EF Core Integration
NodaTime doesn't map to SQL types out of the box, but there are excellent packages for this.
For PostgreSQL: Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime
// In your DbContext configuration
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseNpgsql(connectionString, o => o.UseNodaTime());
}
This maps:
-
Instant→timestamp with time zone -
LocalDateTime→timestamp without time zone -
LocalDate→date -
LocalTime→time
What about ZonedDateTime? There's no single PostgreSQL type for it — that's the whole point of our pattern. You decompose it into separate columns:
-
LocalDateTime→timestamp without time zone -
TimeZoneId→text - Optionally:
Instant→timestamp with time zone(for queries)
Here's how to extract the parts from a ZonedDateTime:
ZonedDateTime zoned = local.InZoneLeniently(zone);
// Decompose for storage
LocalDateTime localPart = zoned.LocalDateTime;
string timeZoneId = zoned.Zone.Id; // e.g. "Europe/Vienna"
Instant instantPart = zoned.ToInstant();
For SQL Server: Consider Value Converters
public class AppointmentConfiguration : IEntityTypeConfiguration<Appointment>
{
public void Configure(EntityTypeBuilder<Appointment> builder)
{
builder.Property(a => a.LocalStart)
.HasConversion(
v => v.ToDateTimeUnspecified(),
v => LocalDateTime.FromDateTime(v));
builder.Property(a => a.InstantUtc)
.HasConversion(
v => v.ToDateTimeUtc(),
v => Instant.FromDateTimeUtc(v));
builder.Property(a => a.TimeZoneId)
.HasMaxLength(64);
}
}
Sample Entity
public class Appointment
{
public Guid Id { get; init; }
public string Title { get; init; }
// Stored as timestamp without time zone
public LocalDateTime LocalStart { get; init; }
// Stored as text/varchar
public string TimeZoneId { get; init; }
// Stored as timestamp with time zone (for queries)
public Instant InstantUtc { get; init; }
}
Handling DST Transitions
When creating appointments that might fall in DST gaps or overlaps, be explicit:
public class AppointmentService
{
public ZonedDateTime ResolveLocalTime(LocalDateTime local, string timeZoneId)
{
var zone = DateTimeZoneProviders.Tzdb[timeZoneId];
var mapping = zone.MapLocal(local);
return mapping.Count switch
{
0 => zone.AtLeniently(local), // Gap: shift forward to valid time
1 => mapping.Single(), // Normal: exactly one mapping
2 => mapping.First(), // Overlap: pick earlier occurrence
_ => throw new InvalidOperationException()
};
}
}
For more control (e.g., asking the user to choose during overlaps):
public ZonedDateTime ResolveWithUserChoice(
LocalDateTime local,
string timeZoneId,
Func<ZonedDateTime, ZonedDateTime, ZonedDateTime> overlapResolver)
{
var zone = DateTimeZoneProviders.Tzdb[timeZoneId];
var mapping = zone.MapLocal(local);
return mapping.Count switch
{
0 => zone.AtLeniently(local),
1 => mapping.Single(),
2 => overlapResolver(mapping.First(), mapping.Last()),
_ => throw new InvalidOperationException()
};
}
Converting from DateTime
If you have existing code using DateTime, here's how to convert:
// DateTime (UTC) to Instant
DateTime dtUtc = DateTime.UtcNow;
Instant instant = Instant.FromDateTimeUtc(dtUtc);
// DateTime (unspecified) to LocalDateTime
DateTime dt = new DateTime(2026, 6, 5, 10, 0, 0);
LocalDateTime local = LocalDateTime.FromDateTime(dt);
// Instant to DateTime (UTC)
DateTime backToUtc = instant.ToDateTimeUtc();
// LocalDateTime to DateTime (unspecified)
DateTime backToUnspecified = local.ToDateTimeUnspecified();
Testing Time-Dependent Code
Code that calls SystemClock.Instance.GetCurrentInstant() directly is hard to test. You can't control "now".
NodaTime solves this with IClock:
// Production: inject the real clock
public class AppointmentService(IClock clock)
{
public bool IsUpcoming(Appointment appointment)
{
var now = clock.GetCurrentInstant();
return appointment.InstantUtc > now;
}
}
// In production
var service = new AppointmentService(SystemClock.Instance);
// In tests: use a fake clock
var fakeNow = Instant.FromUtc(2026, 6, 5, 8, 0);
var fakeClock = new FakeClock(fakeNow);
var service = new AppointmentService(fakeClock);
// Now you can test time-dependent logic deterministically
Rule: Never call SystemClock.Instance directly in business logic. Inject IClock instead. Your tests will thank you.
Key Takeaways
- Use NodaTime for anything beyond simple logging — it gives you the right types for the right concepts
-
Instantfor physical moments (logs, events, tokens) -
LocalDatefor calendar dates (birthdays, holidays) -
LocalDateTime+DateTimeZonefor human-scheduled times (meetings, deadlines) -
Store intent:
LocalDateTime+TimeZoneIdas your source of truth -
Derive instant: compute
InstantUtcfor queries and sorting -
Handle DST explicitly: use
InZoneLenientlyor checkMapLocalfor edge cases -
EF Core: use
Npgsql.EntityFrameworkCore.PostgreSQL.NodaTimefor PostgreSQL, or value converters for other databases
Next up: PostgreSQL – Storing Time Without Lying to Yourself — the database side of the equation.
Top comments (0)