DEV Community

Cover image for .NET in Practice – Modeling Time with NodaTime
bwi
bwi

Posted on

.NET in Practice – Modeling Time with NodaTime

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?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 Instant type (you use DateTime with Kind.Utc or DateTimeOffset)
  • No LocalDateTime with proper semantics (you use DateTime with Kind.Unspecified)
  • No ZonedDateTime that combines local time with a timezone
  • No first-class IANA timezone support (TimeZoneInfo uses 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; }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;)
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

This maps:

  • Instanttimestamp with time zone
  • LocalDateTimetimestamp without time zone
  • LocalDatedate
  • LocalTimetime

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:

  • LocalDateTimetimestamp without time zone
  • TimeZoneIdtext
  • Optionally: Instanttimestamp 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();
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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()
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
    };
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  • Instant for physical moments (logs, events, tokens)
  • LocalDate for calendar dates (birthdays, holidays)
  • LocalDateTime + DateTimeZone for human-scheduled times (meetings, deadlines)
  • Store intent: LocalDateTime + TimeZoneId as your source of truth
  • Derive instant: compute InstantUtc for queries and sorting
  • Handle DST explicitly: use InZoneLeniently or check MapLocal for edge cases
  • EF Core: use Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime for 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)