Skip to content

Pragmatic.Temporal

Type-safe date/time handling with DST-aware arithmetic and automatic timezone management for .NET 10.

.NET’s DateTime and DateTimeOffset are ambiguous:

  • DateTime conflates date, time, and timezone into one type with a Kind property that is easily ignored
  • DateTimeOffset preserves offset but not timezone (two offsets can map to different zones)
  • Calendar arithmetic is broken: new DateTime(2024, 1, 31).AddMonths(1) produces 2024-02-29 but the behavior is not always intuitive
  • DST transitions cause silent bugs when local times are ambiguous or non-existent
  • DateTime.Now in code makes testing non-deterministic and flaky

Pragmatic.Temporal provides types where the type itself defines the temporal scope. A LocalDate cannot accidentally carry time information. A ZonedDateTime cannot lose its timezone. The compiler enforces correct usage.

using Pragmatic.Temporal.Types;
// Calendar date -- no time, no timezone
var birthday = new LocalDate(1990, 5, 15);
// Time of day -- no date, no timezone
var opening = new LocalTime(9, 0);
// Wall clock datetime -- no timezone
var appointment = new LocalDateTime(2026, 3, 21, 14, 30);
// Full timezone awareness
var flight = ZonedDateTime.FromUtc(DateTimeOffset.UtcNow, "Europe/Rome");

PackageRole
Pragmatic.TemporalCore types, calendar arithmetic, business days, cron, IClock
Pragmatic.Temporal.JsonSystem.Text.Json converters for all temporal types
Pragmatic.Temporal.EntityFrameworkCoreEF Core value converters, type mapping, model conventions
Pragmatic.Temporal.AspNetCoreMiddleware, timezone detection strategies, model binding
Pragmatic.Temporal.TestingTestClock, TestTemporalContext, TestHolidayProvider
Pragmatic.Temporal.AnalyzersRoslyn analyzers: PRAG0900-PRAG0904
Terminal window
dotnet add package Pragmatic.Temporal
dotnet add package Pragmatic.Temporal.Json # Optional: JSON support
dotnet add package Pragmatic.Temporal.EntityFrameworkCore # Optional: EF Core
dotnet add package Pragmatic.Temporal.AspNetCore # Optional: ASP.NET Core
dotnet add package Pragmatic.Temporal.Testing # Optional: Test utilities

using Pragmatic.Temporal;
// Dates without time — birthdays, deadlines
var birthday = new LocalDate(1990, 3, 15);
var age = LocalDate.Today.Year - birthday.Year;
// Times without date — schedules, opening hours
var checkIn = new LocalTime(14, 0);
// Date + time without timezone — local events
var meeting = new LocalDateTime(2025, 6, 15, 10, 30);
// Full timezone-aware — flight departures, global scheduling
var departure = ZonedDateTime.Now(TimeZoneInfo.FindSystemTimeZoneById("Europe/Rome"));
// Testable clock — inject IClock instead of using DateTime.Now
IClock clock = new SystemClock();
var now = clock.GetCurrentInstant();

Calendar date without time or timezone. Use for birthdays, holidays, deadlines, reporting dates.

var today = LocalDate.Today;
var date = new LocalDate(2026, 3, 21);
// Properties
date.Year // 2026
date.Month // 3
date.Day // 21
date.DayOfWeek // DayOfWeek.Saturday
date.DayOfYear // 80
date.IsWeekend // true
date.IsWeekday // false
// Arithmetic
date.AddDays(7) // 2026-03-28
date.AddMonths(1) // 2026-04-21
date.AddYears(1) // 2027-03-21
date.DaysBetween(other) // days between two dates
// Navigation
date.StartOfMonth() // 2026-03-01
date.EndOfMonth() // 2026-03-31
date.StartOfYear() // 2026-01-01
date.EndOfYear() // 2026-12-31
// Combine with time
date.At(new LocalTime(14, 30)) // LocalDateTime

Backed by DateOnly internally. Implements IEquatable<LocalDate>, IComparable<LocalDate>.

Time of day without date or timezone. Use for store hours, meeting times, alarm times.

var time = new LocalTime(14, 30);
var withSeconds = new LocalTime(14, 30, 45);
// Properties
time.Hour // 14
time.Minute // 30
time.Second // 0
// Common times
LocalTime.Midnight // 00:00:00
LocalTime.Noon // 12:00:00

Backed by TimeOnly internally.

Date and time without timezone. Use for wall clock appointments, recurring meetings, schedules.

var dt = new LocalDateTime(2026, 3, 21, 14, 30);
// Or compose from parts
var date = new LocalDate(2026, 3, 21);
var time = new LocalTime(14, 30);
var dt2 = new LocalDateTime(date, time);
// Properties
dt.Date // LocalDate
dt.Time // LocalTime
// Convert to zoned
dt.InZone("Europe/Rome") // ZonedDateTime

A specific instant in time with full timezone awareness. Use for flight times, global events, anything that happened or will happen at a specific moment.

// From UTC instant + timezone
var rome = ZonedDateTime.FromUtc(DateTimeOffset.UtcNow, "Europe/Rome");
// From local time in a zone
var local = new LocalDateTime(2026, 3, 21, 14, 30);
var zoned = ZonedDateTime.FromLocal(local, "America/New_York");
// Current time in a zone
var now = ZonedDateTime.NowIn("Europe/Rome");
// Properties
rome.UtcDateTime // DateTimeOffset (UTC)
rome.Zone // TimeZoneInfo
rome.ZoneId // "Europe/Rome"
rome.Offset // TimeSpan (+01:00 or +02:00 depending on DST)
rome.LocalDateTime // DateTime in that zone
rome.Date // LocalDate in that zone
rome.Time // LocalTime in that zone
rome.IsDaylightSavingTime // bool
// Timezone conversion
rome.InZone("Asia/Tokyo") // Convert to Tokyo time
rome.ToUtc() // Convert to UTC

Constructors are restricted. Use factory methods to ensure DST safety.

Immutable elapsed physical time. Unlike calendar arithmetic, Duration.FromDays(1) is always exactly 24 hours.

var d = Duration.FromHours(2) + Duration.FromMinutes(30);
// Factory methods
Duration.FromDays(7)
Duration.FromHours(2)
Duration.FromMinutes(30)
Duration.FromSeconds(45)
// Common durations
Duration.Zero
Duration.OneDay // 24 hours
Duration.OneHour
Duration.OneMinute
// Properties
d.TotalHours // 2.5
d.TotalMinutes // 150.0
// Arithmetic
var sum = d1 + d2;
var diff = d1 - d2;
var scaled = d1 * 2;

Calendar-based duration in years, months, and days. A Period of “1 month” added to January 31 results in February 28/29, not an error.

var oneMonth = Period.FromMonths(1);
var oneYear = Period.FromYears(1);
var custom = new Period(1, 2, 3); // 1 year, 2 months, 3 days
// Apply to dates
var nextMonth = today.Add(oneMonth); // Jan 31 -> Feb 28
var expiryDate = startDate.Add(oneYear);
// ISO 8601 format
var parsed = Period.Parse("P1Y2M3D"); // 1 year, 2 months, 3 days
period.ToString() // "P1Y2M3D"
// Compute between dates
var age = Period.Between(birthDate, today);

Closed date range [Start, End] with set operations. Use for reporting periods, booking windows, overlap detection.

// Factory methods
var q1 = DateRange.Quarter(2026, 1);
var march = DateRange.Month(2026, 3);
var week = DateRange.Week(new LocalDate(2026, 3, 21));
var single = DateRange.SingleDay(new LocalDate(2026, 3, 21));
// Properties
q1.Start // 2026-01-01
q1.End // 2026-03-31
q1.Days // 90 (inclusive)
q1.IsSingleDay // false
q1.Duration // Duration
// Set operations
q1.Contains(new LocalDate(2026, 2, 15)) // true
q1.Overlaps(march) // true
q1.Intersect(march) // DateRange [Mar 1 - Mar 31]
// Enumeration
foreach (var date in q1) { /* iterates all dates */ }

Zero-dependency cron expression parser supporting standard 5-field and extended 6-field (with seconds) formats.

// Common expressions (static properties)
CronExpression.EveryMinute // "* * * * *"
CronExpression.EveryHour // "0 * * * *"
CronExpression.Midnight // "0 0 * * *"
CronExpression.Noon // "0 12 * * *"
CronExpression.Weekdays // "0 0 * * 1-5"
CronExpression.Weekends // "0 0 * * 0,6"
// Factory methods
CronExpression.EveryMinutes(15)
CronExpression.Daily(new TimeOnly(9, 0))
CronExpression.Weekly(DayOfWeek.Monday, new TimeOnly(8, 0))
CronExpression.Monthly(1, new TimeOnly(0, 0))
// Custom parsing
var cron = CronExpression.Parse("0/15 9-17 * * 1-5"); // Every 15 min during business hours
// Supported syntax: *, ranges (1-5), lists (1,3,5), steps (*/5), L, W, #
// Get occurrences
var next = cron.GetNextOccurrence(DateTimeOffset.UtcNow);
var nextFive = cron.GetNextOccurrences(DateTimeOffset.UtcNow, 5);
// Properties
cron.Expression // original string
cron.HasSeconds // true for 6-field
cron.Semantics // Unix (OR) or Quartz (AND) day matching

Never use DateTime.Now directly. Inject IClock for testable time:

public interface IClock
{
DateTimeOffset UtcNow { get; }
DateTimeOffset Now { get; }
DateOnly UtcToday { get; }
DateOnly Today { get; }
TimeOnly UtcTimeOfDay { get; }
TimeOnly TimeOfDay { get; }
TimeProvider GetTimeProvider();
}

SystemClock delegates to TimeProvider.System:

services.AddSingleton<IClock, SystemClock>();
// Or use the singleton instance:
IClock clock = SystemClock.Instance;

TestClock allows full time control:

var clock = new TestClock();
clock.SetDateTime(2026, 3, 21, 10, 0, 0);
clock.Advance(TimeSpan.FromHours(2));
clock.AdvanceDays(1);
// Fluent API
var clock = TestClock.AtNoon(2026, 3, 21)
.WithAutoAdvance(TimeSpan.FromMilliseconds(1));
// DST test helpers
clock.SetBeforeRomeSpringForward(2026);
clock.SetBeforeUsEasternSpringForward(2026);

ITemporalCalculator provides O(1) business day calculations with pluggable holiday providers.

var calculator = new TemporalCalculator(); // No holidays
var delivery = calculator.AddBusinessDays(today, 5);
// With holidays
var holidays = StaticHolidayProvider.CreateBuilder()
.AddHoliday("IT", 2026, 12, 25, "Christmas")
.AddHoliday("IT", 2026, 12, 26, "St. Stephen's Day")
.Build();
var calculator = new TemporalCalculator(holidays);
var delivery = calculator.AddBusinessDays(today, 5, "IT");
MethodDescription
AddBusinessDays(from, days)Add/subtract business days (weekends only)
AddBusinessDays(from, days, countryCode)Add/subtract business days (weekends + holidays)
CountBusinessDays(from, to)Count business days in range
IsBusinessDay(date)Check if date is a business day
IsHoliday(date, countryCode)Check if date is a holiday
IsWeekend(date)Check if date is Saturday or Sunday
NextBusinessDay(from)Get next business day
PreviousBusinessDay(from)Get previous business day
StartOfWeek(date)First day of the week
StartOfMonth(date)First day of the month
StartOfQuarter(date)First day of the quarter
EndOfWeek(date)Last day of the week
EndOfMonth(date)Last day of the month

Implement for custom holiday sources:

public interface IHolidayProvider
{
IEnumerable<string> SupportedCountries { get; }
IEnumerable<Holiday> GetHolidays(int year, string countryCode);
IEnumerable<Holiday> GetHolidays(int year, string countryCode, string? regionCode);
bool IsHoliday(LocalDate date, string countryCode);
}

Built-in providers:

  • NoHolidaysProvider — default, returns no holidays
  • StaticHolidayProvider — configurable list with fluent builder

Holiday types: Public, Regional, Bank, Optional.


Extension methods on LocalDate for relative navigation:

var today = new LocalDate(2026, 3, 21); // Saturday
// Day-of-week navigation
today.Next(DayOfWeek.Monday) // 2026-03-23
today.Previous(DayOfWeek.Friday) // 2026-03-20
today.NextOrSame(DayOfWeek.Saturday) // 2026-03-21 (same day)
// Month-based
today.FirstInMonth(DayOfWeek.Monday) // 2026-03-02
today.LastInMonth(DayOfWeek.Friday) // 2026-03-27
today.NthInMonth(2, DayOfWeek.Tuesday) // 2nd Tuesday = 2026-03-10
today.NthInMonth(-1, DayOfWeek.Sunday) // Last Sunday
// Year-based
today.FirstInYear(DayOfWeek.Monday)
today.LastInYear(DayOfWeek.Friday)
// Business day helpers
today.NextWeekday()
today.PreviousWeekday()
today.NearestWeekday() // Sat->Fri, Sun->Mon
// ISO week
today.IsoWeekOfYear() // ISO 8601 week number
today.IsoWeekYear() // ISO week year (may differ near year boundary)
LocalDateExtensions.FromIsoWeekDate(2026, 12, DayOfWeek.Monday)

Pragmatic.Temporal provides explicit policies for DST transitions instead of silent defaults.

// Europe/Rome: March 29, 2026 at 02:00 clocks jump to 03:00
var local = new LocalDateTime(2026, 3, 29, 2, 30);
// Policy: shift to next valid time
var zoned = ZonedDateTime.FromLocal(local, "Europe/Rome",
nonExistentTimePolicy: NonExistentTimePolicy.ShiftForward);
// Result: 03:00 (first valid time after the gap)
// Policy: throw
ZonedDateTime.FromLocal(local, "Europe/Rome",
nonExistentTimePolicy: NonExistentTimePolicy.ThrowException);
// Throws NonExistentTimeException
// Europe/Rome: October 25, 2026 at 03:00 clocks fall back to 02:00
var local = new LocalDateTime(2026, 10, 25, 2, 30);
// Policy: use standard time (later occurrence)
var standard = ZonedDateTime.FromLocal(local, "Europe/Rome",
ambiguousTimePolicy: AmbiguousTimePolicy.UseStandardTime);
// Result: 02:30 +01:00
// Policy: use daylight time (earlier occurrence)
var daylight = ZonedDateTime.FromLocal(local, "Europe/Rome",
ambiguousTimePolicy: AmbiguousTimePolicy.UseDaylightTime);
// Result: 02:30 +02:00
// Policy: throw
ZonedDateTime.FromLocal(local, "Europe/Rome",
ambiguousTimePolicy: AmbiguousTimePolicy.ThrowException);
// Throws AmbiguousTimeException

The middleware creates a TemporalContext per request with the detected client timezone:

app.UseTemporalContext();

Detection strategies (evaluated in priority order):

StrategySourcePriority
QueryStringTimeZoneStrategy?tz=Europe/Rome10
HeaderTimeZoneStrategyX-Timezone: Europe/Rome20
ClaimsTimeZoneStrategyJWT claim timezone30
CookieTimeZoneStrategyCookie tz40

Implement ITimeZoneDetectionStrategy for custom detection.

LocalDateModelBinder handles query string / route parameter binding for LocalDate.


Configure temporal services in Program.cs:

await PragmaticApp.RunAsync(args, app =>
{
app.UseTemporal(temporal =>
{
temporal.UseDefaultTimeZone("Europe/Rome");
temporal.UseBusinessTimeZone("Europe/Rome");
temporal.UseHolidayProvider<ItalianHolidayProvider>();
temporal.UseClock<SystemClock>();
});
});
MethodDescription
UseDefaultTimeZone(string)Set default timezone by IANA ID
UseDefaultTimeZone(TimeZoneInfo)Set default timezone
UseBusinessTimeZone(string)Set business timezone
UseHolidayProvider<T>()Register custom holiday provider
UseHolidayProvider(instance)Register holiday provider instance
UseClock<T>()Replace the default clock
UseClock(instance)Replace clock with specific instance

IDSeverityDescription
PRAG0900WarningDateTime.Now, .UtcNow, DateTimeOffset.Now, .UtcNow — use IClock
PRAG0901WarningDateTime.Today — use IClock.Today
PRAG0902Warningnew DateTime() without DateTimeKind — ambiguous Kind
PRAG0903WarningDateTimeOffset relational comparison (<, >) without .UtcDateTime
PRAG0904InfoDateTime.Now/.UtcNow in test code — use TestClock

Full-featured clock for deterministic time in tests:

var clock = new TestClock();
clock.SetDateTime(2026, 3, 21, 10, 0, 0);
// Advance time
clock.Advance(TimeSpan.FromHours(2));
clock.AdvanceDays(1);
clock.AdvanceHours(3);
// Static factories
var clock = TestClock.AtNoon(2026, 3, 21);
var clock = TestClock.AtMidnight(2026, 1, 1);
var clock = TestClock.AtNow();
// Auto-advance (prevents flaky async tests)
clock.WithAutoAdvance(TimeSpan.FromMilliseconds(1));
// DST test helpers
clock.SetBeforeRomeSpringForward(2026);
clock.SetBeforeRomeFallBack(2026);
clock.SetBeforeUsEasternSpringForward(2026);

Pre-configured context for common test scenarios:

var rome = TestTemporalContext.ForRome();
var ny = TestTemporalContext.ForNewYork();
var custom = TestTemporalContext.Create("de-DE", "Europe/Berlin");

Fluent holiday provider for tests:

var holidays = new TestHolidayProvider()
.AddHoliday(2026, 12, 25, "Christmas")
.AddHoliday(2026, 12, 26, "Boxing Day");
var calculator = new TemporalCalculator(holidays);

AspectPragmatic.TemporalNodaTime
Timezone data.NET BCL (always current via OS)Ships own TZDB (needs updates)
CalendarsGregorian onlyMultiple (Islamic, Hebrew, etc.)
Business daysBuilt-in with holiday providersNot included
Cron expressionsBuilt-in parserNot included
ASP.NET CoreDedicated package with middlewareCommunity packages
EF CoreDedicated package with conventionsCommunity packages
AnalyzersPRAG0900-0904Not included
WeightLightweight, focusedComprehensive
IClockBuilt-in with TestClockIClock with FakeClock

GuideWhat You’ll Learn
ConceptsWhy Pragmatic.Temporal exists, type hierarchy, IClock, DST model, ecosystem integration
GuideWhat You’ll Learn
Getting StartedMental model, golden rules, minimal example
Core TypesAll types, storage formats, conversion rules
GuideWhat You’ll Learn
DST HandlingAmbiguous times, non-existent times, explicit policies
Business DaysTemporalCalculator, IHolidayProvider, holiday types
TestingTestClock, TestTemporalContext, deterministic time
GuideWhat You’ll Learn
Common MistakesWrong/Right/Why for the 11 most frequent issues
TroubleshootingProblem/Checklist format, diagnostics reference, FAQ
TopicSummary
PeriodCalendar-based duration, ISO 8601 format, Period.Between()
DateRangeMonth(), Quarter(), Year(), Week(), overlap, intersect, enumeration
CronExpressionParsing, matching, next occurrence, standard and extended syntax
Relative NavigationNext(DayOfWeek), NthInMonth, ISO week support
ASP.NET CoreMiddleware, timezone strategies, TemporalContext, model binding
AnalyzersPRAG0900-0904 warnings for unsafe DateTime usage

  1. Store UTC only — databases should contain UTC timestamps
  2. Convert at boundaries — timezone conversion at API input/output only
  3. Never query with local time — pre-calculate UTC ranges before querying
  4. Use IClock, never DateTime.Now — for testability
  5. Be explicit about DST — always specify a policy for ambiguous/non-existent times
  • .NET 10.0+

Part of the Pragmatic.Design ecosystem.