Skip to content

DST Handling

How Pragmatic.Temporal handles Daylight Saving Time transitions.

When clocks change, two problems occur:

  1. Spring Forward: Some local times don’t exist (e.g., 2:30 AM skipped)
  2. Fall Back: Some local times are ambiguous (e.g., 2:30 AM occurs twice)

Pragmatic.Temporal provides explicit policies for handling these edge cases:

public enum DstPolicy
{
ThrowOnAmbiguous, // Throw exception for ambiguous times
ThrowOnInvalid, // Throw exception for invalid times
SkipForward, // Move invalid time forward to valid time
SkipBackward, // Move invalid time backward to valid time
UseEarlier, // Use earlier offset for ambiguous times
UseLater // Use later offset for ambiguous times
}

When clocks spring forward, some times don’t exist:

// In US Eastern: March 10, 2024, 2:00 AM clocks move to 3:00 AM
// 2:30 AM doesn't exist
var local = new LocalDateTime(2024, 3, 10, 2, 30);
// Option 1: Throw (default for strict applications)
try
{
var zoned = ZonedDateTime.FromLocal(local, "America/New_York", DstPolicy.ThrowOnInvalid);
}
catch (InvalidTimeException ex)
{
// Handle the invalid time
}
// Option 2: Skip forward to valid time
var skipped = ZonedDateTime.FromLocal(local, "America/New_York", DstPolicy.SkipForward);
// Result: 3:30 AM (skipped the gap)
// Option 3: Skip backward (less common)
var backward = ZonedDateTime.FromLocal(local, "America/New_York", DstPolicy.SkipBackward);
// Result: 1:30 AM

When clocks fall back, some times occur twice:

// In US Eastern: November 3, 2024, 2:00 AM clocks move to 1:00 AM
// 1:30 AM occurs twice (once in EDT, once in EST)
var local = new LocalDateTime(2024, 11, 3, 1, 30);
// Option 1: Throw (default for strict applications)
try
{
var zoned = ZonedDateTime.FromLocal(local, "America/New_York", DstPolicy.ThrowOnAmbiguous);
}
catch (AmbiguousTimeException ex)
{
// Handle the ambiguous time
}
// Option 2: Use earlier occurrence (still in Daylight time)
var earlier = ZonedDateTime.FromLocal(local, "America/New_York", DstPolicy.UseEarlier);
// Result: 1:30 AM EDT (UTC-4)
// Option 3: Use later occurrence (in Standard time)
var later = ZonedDateTime.FromLocal(local, "America/New_York", DstPolicy.UseLater);
// Result: 1:30 AM EST (UTC-5)
public class Event
{
// Store as UTC
public DateTimeOffset StartsAt { get; set; }
// Display in user's timezone
public ZonedDateTime GetLocalStart(string timezone)
{
return ZonedDateTime.FromUtc(StartsAt, timezone);
}
}
// Good: Explicit policy
var zoned = ZonedDateTime.FromLocal(local, zone, DstPolicy.SkipForward);
// Avoid: Implicit behavior
var zoned = ZonedDateTime.FromLocal(local, zone); // What happens on DST?
public Result<ZonedDateTime, TemporalError> ParseUserDateTime(
string dateTimeStr,
string timezone)
{
if (!LocalDateTime.TryParse(dateTimeStr, out var local))
return new InvalidFormatError();
try
{
return ZonedDateTime.FromLocal(local, timezone, DstPolicy.ThrowOnInvalid);
}
catch (InvalidTimeException)
{
return new InvalidTimeError($"The time {dateTimeStr} doesn't exist in {timezone}");
}
catch (AmbiguousTimeException)
{
return new AmbiguousTimeError($"The time {dateTimeStr} is ambiguous in {timezone}");
}
}
[Theory]
[InlineData("2024-03-10T02:30:00", "America/New_York", false)] // Invalid
[InlineData("2024-11-03T01:30:00", "America/New_York", true)] // Ambiguous
public void ShouldHandleDstTransitions(string dateTime, string zone, bool isAmbiguous)
{
var local = LocalDateTime.Parse(dateTime);
// Test your handling logic
}
RegionSpring ForwardFall Back
US Eastern2nd Sunday March, 2 AM1st Sunday November, 2 AM
US Pacific2nd Sunday March, 2 AM1st Sunday November, 2 AM
Central EuropeLast Sunday March, 2 AMLast Sunday October, 3 AM
UKLast Sunday March, 1 AMLast Sunday October, 2 AM
Australia (Sydney)1st Sunday October, 2 AM1st Sunday April, 3 AM

Note: Some regions don’t observe DST (e.g., Arizona, most of Asia, Africa).

When scheduling recurring events:

// Problem: "Every day at 2:30 AM" - what happens on DST days?
// Solution: Use wall clock time with explicit policy
public IEnumerable<ZonedDateTime> GetDailyOccurrences(
LocalTime time,
string timezone,
LocalDate start,
int count)
{
var current = start;
for (int i = 0; i < count; i++)
{
var local = current.At(time);
// Skip forward if time doesn't exist (DST spring forward)
var zoned = ZonedDateTime.FromLocal(local, timezone, DstPolicy.SkipForward);
yield return zoned;
current = current.AddDays(1);
}
}