R8.TzDateTime 1.0.1

dotnet add package R8.TzDateTime --version 1.0.1
                    
NuGet\Install-Package R8.TzDateTime -Version 1.0.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="R8.TzDateTime" Version="1.0.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="R8.TzDateTime" Version="1.0.1" />
                    
Directory.Packages.props
<PackageReference Include="R8.TzDateTime" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add R8.TzDateTime --version 1.0.1
                    
#r "nuget: R8.TzDateTime, 1.0.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package R8.TzDateTime@1.0.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=R8.TzDateTime&version=1.0.1
                    
Install as a Cake Addin
#tool nuget:?package=R8.TzDateTime&version=1.0.1
                    
Install as a Cake Tool

R8.TzDateTime

NuGet Downloads CI License: MIT

An immutable, timezone-agnostic DateTime for .NET, built on NodaTime. A TimezoneDateTime is a UTC instant tagged with a timezone: the stored value is just the instant — agnostic — and only becomes a wall-clock date when you read it through the zone's calendar (Gregorian, Persian/Jalali, …) and culture. Values are 16 bytes, allocation-free on hot paths, and Native-AOT compatible.

Only UTC is built in; you register the zones your app needs (culture + calendar are your choice) — see Timezones are agnostic.

Why

DateTimeOffset tells you an instant and an offset, but not which timezone, and it always speaks the Gregorian calendar. TimezoneDateTime keeps the instant timezone-agnostic and resolves the offset, calendar, and culture from the zone you view it through — so the date fields (Year, Month, Day, …) come back right without you converting anything by hand.

Benefits over the alternatives

DateTime DateTimeOffset NodaTime ZonedDateTime TimezoneDateTime
Knows which timezone (not just an offset)
Non-Gregorian calendar (e.g. Persian) per zone manual ✓ (per registered zone)
Culture-aware (RTL, first day of week)
DST-correct arithmetic & day/week/month boundaries
Equal when the instant is equal, across zones
Value size 8 B 16 B larger (holds a zone reference) 16 B
Allocation-free on hot paths mostly
Built-in System.Text.Json support needs a package
Native-AOT clean
  • vs DateTime — carries no timezone (only a fragile Kind) and is Gregorian-only. TimezoneDateTime binds the instant to a real zone and calendar, so reading .Year/.Day never silently uses the wrong timezone or calendar.
  • vs DateTimeOffset — an offset is not a timezone: it can't name the zone, and math across a future DST change is wrong because the offset is frozen. TimezoneDateTime resolves offsets from the zone at the actual instant.
  • vs raw NodaTime — NodaTime gives you the primitives; TimezoneDateTime bundles zone + calendar + culture into one 16-byte value with DST-correct helpers, a built-in JSON converter, and allocation-free hot paths.

Install

dotnet add package R8.TzDateTime

Targets net6.0 and net8.0.

Quick start

using System.Globalization;
using NodaTime;
using R8.TzDateTime;

// Register the zones your app uses once at startup (only UTC is built in).
// Here Tehran is registered with the Persian calendar and the fa-IR culture:
var tehran = LocalTimezone.AddTimezone(
    "Asia/Tehran", CultureInfo.GetCultureInfo("fa-IR"), CalendarSystem.PersianSimple, "Iran");

// A TimezoneDateTime is a UTC instant tagged with a zone — agnostic until you read it.
var utc = new DateTime(2024, 3, 20, 20, 30, 0, DateTimeKind.Utc);
var value = new TimezoneDateTime(utc, tehran);

value.Year;        // 1403  (Persian, because Tehran was registered with the Persian calendar)
value.Month;       // 1     (Farvardin)
value.ToString();  // formatted with the fa-IR culture

// The same instant, viewed through another zone — equal, because equality is by instant.
var istanbul = LocalTimezone.AddTimezone("Europe/Istanbul", CultureInfo.GetCultureInfo("tr-TR"), CalendarSystem.Gregorian);
value == value.WithTimezone(istanbul); // true

Core ideas

  • The value is timezone-agnostic. Internally it is just UTC ticks plus a timezone index; the instant is absolute. Equality and comparison are by the instant only — two values at the same moment in different zones are equal.
  • A zone gives it a calendar and culture. Reading .Year/.Day, formatting, and week boundaries all resolve through the zone you registered — so the same instant reads as 1403 in a Persian-calendar zone and 2024 in a Gregorian one.
  • Wall-clock constructor args are in the zone's calendar. new TimezoneDateTime(1403, 1, 1, tehran) is Nowruz 1403, not a Gregorian date.
  • Immutable. Every Add*/GetStartOf*/WithTimezone returns a new value.

Timezones are agnostic — you register what you need

Only UTC is built in. The library doesn't bake in a zone list; you teach it the zones your app cares about via AddTimezone, then values reference them. Registration is a thread-safe, idempotent runtime API — call it once at startup (registering the same id again just returns the existing zone).

// id + culture + calendar (+ optional aliases the zone also answers to)
LocalTimezone.AddTimezone("Asia/Tehran",  CultureInfo.GetCultureInfo("fa-IR"), CalendarSystem.PersianSimple, "Iran");
LocalTimezone.AddTimezone("Europe/London", CultureInfo.GetCultureInfo("en-GB"), CalendarSystem.Gregorian);
LocalTimezone.AddTimezone("America/New_York", CultureInfo.GetCultureInfo("en-US"), CalendarSystem.Gregorian, "US/Eastern");
  • culture drives formatting, first-day-of-week, and RTL.
  • calendar is any NodaTime CalendarSystem (Gregorian, Iso, PersianSimple, …).
  • aliases are extra ids the zone also resolves from.

Resolve and check — by IANA id, an alias, or the Windows timezone id:

LocalTimezone.GetTimezone("Asia/Tehran");        // IANA id
LocalTimezone.GetTimezone("Iran");               // registered alias
LocalTimezone.GetTimezone("Iran Standard Time"); // Windows id (auto-mapped from CLDR)
LocalTimezone.TryGetTimezone("Iran", out var tz);
LocalTimezone.Utc;                               // the one built-in zone
LocalTimezone.Timezones;                         // everything registered so far (cached snapshot)

Each registered zone is also resolvable by its Windows id (e.g. Iran Standard TimeAsia/Tehran), mapped automatically via NodaTime's CLDR data. The Windows id is a resolution alias only — it is not added to IanaIds. ("… Daylight Time" is a localized display name, not an id, so it does not resolve — the Windows id, which covers both standard and DST, does.)

Prefer a class? There are AddTimezone(LocalTimezoneOptions) and AddTimezone<TOptions>() overloads for subclasses of LocalTimezoneOptions.

Why register instead of shipping a big list? A zone's culture and calendar are decisions, not facts: a country maps to several cultures, and tzdb has no notion of which calendar you want to display. Only your app knows that Asia/Tehran should render with fa-IR and the Persian calendar. Registering keeps that choice explicit and the shipped surface minimal.

Usage

Constructing

new TimezoneDateTime(utcDateTime, timezone);      // from a DateTime instant
new TimezoneDateTime(ticks, timezone);            // from UTC ticks
new TimezoneDateTime(1403, 1, 1, tehran);         // wall-clock, in the zone's calendar
new TimezoneDateTime(1403, 1, 1, 9, 30, 0, tehran); // with time
TimezoneDateTime.Now;                             // now, in LocalTimezone.Current
utcDateTime.ToTimezoneDateTime(timezone);         // extension method

Reading

value.Year;  value.Month;  value.Day;
value.Hour;  value.Minute; value.Second; value.Millisecond;
value.DayOfWeek;
value.Ticks;                 // UTC ticks
value.GetUtcDateTime();      // DateTime (Utc kind)
value.GetDateTime();         // wall-clock DateTime in the zone
value.GetTimezone();         // the associated timezone
value.IsDaylightSavingTime();

Arithmetic and boundaries

value.AddYears(1); value.AddMonths(-2); value.AddDays(10);
value.AddHours(3); value.AddMinutes(15); value.AddSeconds(30);
value.Add(TimeSpan.FromHours(2)); value.Subtract(other); // TimeSpan between two values

value.GetStartOfDay();   value.GetEndOfDay();
value.GetStartOfMonth(); value.GetEndOfMonth();
value.GetStartOfWeek();  value.GetEndOfWeek();   // week honors the culture's first day
value.GetStartOfNextDay(); value.GetStartOfNextMonth(); value.GetStartOfNextWeek();

DST gaps and overlaps are resolved leniently (skipped local times shift forward; ambiguous ones take the earlier offset).

Formatting

value.ToString();                 // general format, in the zone's culture
value.ToString("yyyy/MM/dd");     // any standard/custom date format
value.ToString(format, culture);  // override the culture

Humanize (relative time)

value.Humanize();                                    // "2 hours ago", "yesterday at 09:30", "last week"
value.Humanize(maxRelativity: TimeSpan.FromDays(7)); // fall back to a formatted date beyond the window
value.Humanize(compareAgainst: someUtcDateTime);

Ambient timezone and scopes

LocalTimezone.Current is the default timezone for TimezoneDateTime.Now (it falls back to UTC when the machine's zone hasn't been registered). In a web app, set it per request with a scope (backed by AsyncLocal):

LocalTimezone.StartScope(tehran);
try
{
    var now = TimezoneDateTime.Now; // uses Tehran
}
finally
{
    LocalTimezone.EndScope();
}

JSON

TimezoneDateTime serializes with System.Text.Json out of the box. The wire format is the UTC instant as an ISO-8601 string — the timezone is not part of the payload, so values deserialize in UTC.

var json = JsonSerializer.Serialize(value);          // "2024-03-20T20:30:00Z"
var back = JsonSerializer.Deserialize<TimezoneDateTime>(json);

Performance

Timezone-aware operations vs the BCL DateTime + TimeZoneInfo equivalent, for a Gregorian zone (Europe/Istanbul) so both sides do the same work. BenchmarkDotNet, .NET 8, Apple M-series; lower is better.

Operation DateTime + TimeZoneInfo TimezoneDateTime Speedup Allocations
Convert an instant to a zone and read Y/M/D/H/M 46.2 ns 37.7 ns ~1.2× none
Add days (DST-correct) 113.9 ns 6.8 ns ~17× none
Start of day in the zone 118.7 ns 4.6 ns ~26× none
Format with the zone's culture 94.8 ns 58.5 ns ~1.6× 56 B (the string)

The large gaps on add/start-of-day come from the fast paths: instants past a zone's last DST transition resolve with plain arithmetic instead of a zone-interval lookup, and no round-trip conversion is needed. Reproduce with dotnet run -c Release --project benchmarks/R8.TzDateTime.Benchmarks.csproj.

Native AOT & trimming

The library is trim- and Native-AOT-clean and ships with the analyzers enabled (IsAotCompatible on net8; trim analysis on net6). Three things to know when publishing AOT:

  • net8.0 onlyPublishAot is not available on net6.
  • Requires non-invariant globalization. The point rests on cultures like fa-IR (Persian calendar, RTL, first-day-of-week). Do not set <InvariantGlobalization>true</InvariantGlobalization>, or those collapse to the invariant culture.
  • JSON under AOT — the converters use no reflection, but as with any type you serialize under AOT, supply a source-generated JsonSerializerContext/resolver.

Building & testing

dotnet build                                            # both TFMs
dotnet test tests/R8.TzDateTime.Tests.csproj -f net6.0         # net6 (xunit)
dotnet run  --project tests/R8.TzDateTime.Tests.csproj -f net8.0  # net8 (xunit.v3 self-runner)

Repo layout: src/ (library), tests/, benchmarks/, samples/ (Native-AOT smoke test).

License

MIT — free for everyone, including commercial use.

Product Compatible and additional computed target framework versions.
.NET net6.0 is compatible.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.1 88 7/4/2026
1.0.0 96 7/2/2026