Time Zones

EverTask computes every schedule in UTC unless you say otherwise. InTimeZone says otherwise: it reads the schedule’s calendar on a real clock, so “09:00” stays 09:00 there all year, including the weekends the clocks move.

await dispatcher.Dispatch(
    new SendDailyDigestTask(),
    r => r.Schedule().EveryDay().AtTime(new TimeOnly(9, 0)).InTimeZone("Europe/Rome"),
    taskKey: "daily-digest");

That schedule fires at 07:00Z in July and at 08:00Z in January. The instant moves; the local hour does not.

Where the call sits in the chain makes no difference: Schedule().InTimeZone(z).EveryDay().AtTime(...), EveryDay().InTimeZone(z).AtTime(...) and EveryDay().AtTime(...).InTimeZone(z) build the same schedule. The one gap is between Every(n) and its unit: Every(3) has no shape to read yet, so name the zone before it on Schedule(), or after it on the builder that follows.

Why Not Convert the Time Yourself

The obvious workaround is to convert a local time to UTC once, at registration, and schedule the result:

// Wrong: the offset is frozen at the moment you register
var zone   = TimeZoneInfo.FindSystemTimeZoneById("Europe/Rome");
var utc9am = TimeZoneInfo.ConvertTimeToUtc(DateTime.Today.AddHours(9), zone);

await dispatcher.Dispatch(
    new SendDailyDigestTask(),
    r => r.Schedule().EveryDay().AtTime(TimeOnly.FromDateTime(utc9am)));

Registering that in July stores 07:00 UTC. Come the end of October the task starts arriving at 08:00 local, and it stays wrong until someone re-registers it. Storing BaseUtcOffset has the same problem in reverse: it is the zone’s standard offset, so a schedule built with it is an hour off for the whole daylight-saving season.

Give EverTask the zone and it resolves the offset at each occurrence instead.

AtTime and AtTimes store what you pass, unchanged. The time of day is read on whatever clock the schedule ends up on: the zone you named, or UTC when you named none. There is nothing for the builder to convert.

TimeOnly.ToUniversalTime() Was Removed in 4.0

EverTask.Scheduler.Recurring.DateTimeOffsetExtensions.ToUniversalTime(this TimeOnly) was the same idea as the workaround above: declare every time of day in UTC. Despite the name it never converted anything. It rebuilt the value from today’s UTC date, whose offset is zero, so all it ever did was drop the milliseconds.

Pass the local time you mean and name the zone.

r.Schedule().EveryDay().AtTime(new TimeOnly(9, 0)).InTimeZone("Europe/Rome");

What a Zone Governs

Not every schedule has anything for a zone to move. EverTask classifies each one:

Semantics Schedules What a zone does
Calendar EveryDay, EveryWeek, EveryMonth, Every(n).Days()/.Weeks()/.Months(), OnDays, OnMonths, AtTime/AtTimes, UseCron Governs them. The calendar is read on the zone’s clock.
Elapsed Every(n).Seconds(), Every(n).Minutes(), Every(n).Hours(), with AtSecond / AtMinute Nothing on the grid. A day/date exclusion may still use the zone as its calendar clock.

A cadence in days, weeks or months sits in the first row, not the second. It lands on a time of day (midnight, if you never named one), and a time of day only means something on a clock. Every(3).Days() in Rome fires at local midnight, so the step across the March transition is 71 hours and the one across October is 73. Seconds, minutes and hours have no such component to read.

InTimeZone on an elapsed schedule without day/date exclusions throws InvalidOperationException when the schedule is built, rather than being accepted and quietly ignored:

// Throws: "every 30 minutes" is the same set of instants everywhere
r.Schedule().Every(30).Minutes().InTimeZone("Europe/Rome");

You don’t have to wait for the host to start to find out. The analyzer bundled in EverTask.Abstractions reports ET0010 on that line at compile time, with the same explanation:

warning ET0010: 'InTimeZone' throws when this schedule is built: a plain cadence (every N
seconds/minutes/hours) is a constant step in elapsed time and produces the same instants in every zone;
anchor the schedule to a calendar (a time of day, a day of the week, a month selector, a cron
expression or a day/date exclusion) or drop the call

It is a warning, not the guard: the exception is what enforces the rule, and the analyzer considers the completed chain, so Except/ExceptWeekends before or after InTimeZone suppresses it. A chain broken over a variable or returned by a helper method gets nothing, so a clean build is not a promise that every schedule is fine. Tune or silence it like any other diagnostic:

# .editorconfig
dotnet_diagnostic.ET0010.severity = error   # or none

One consequence worth remembering: AtMinute and AtSecond refine an elapsed cadence, so they align on UTC. In a zone with a fractional offset, EveryHour().AtMinute(30) fires at :00 local in India (+05:30) and at :15 local in Nepal (+05:45). If you need a local minute, anchor the schedule to a calendar instead: EveryDay().AtTimes(...), or a cron expression.

The Stored Id

The zone travels with the schedule as an IANA id inside the persisted definition. There is no new column, and nothing about an existing row changes.

r.Schedule().EveryDay().AtTime(new TimeOnly(9, 0)).InTimeZone("Europe/Rome");            // IANA
r.Schedule().EveryDay().AtTime(new TimeOnly(9, 0)).InTimeZone("W. Europe Standard Time"); // Windows id
r.Schedule().EveryDay().AtTime(new TimeOnly(9, 0)).InTimeZone(TimeZoneInfo.Local);        // a TimeZoneInfo

All three are accepted; all three are stored in the IANA spelling, so a row written on Windows resolves on a Linux replica of the same deployment. An id this machine cannot resolve is refused at registration with ArgumentException, before anything is written.

Two ids are refused outright:

  • a zone built with TimeZoneInfo.CreateCustomTimeZone, whose rules live only in the current process and could never be restored from a row;
  • anything TimeZoneInfo.FindSystemTimeZoneById does not know.

A schedule that never calls InTimeZone stores no id at all, which is what the historical UTC behaviour looks like. InTimeZone(TimeZoneInfo.Utc) stores "UTC": same arithmetic, but the row now records that someone chose it.

If a stored id stops resolving (the zone was removed from the system database, or the row moved to a host with a smaller one), startup recovery treats the row the way it treats an unparseable cron expression: the schedule is marked Failed permanently rather than run at a time nobody chose.

Daylight Saving

Twice a year a zone’s clock skips an hour or repeats one, and a time of day is then either missing or duplicated. Both cases have a defined answer here, so you do not have to special-case them in a handler.

A slot the clock skipped fires at the first local time that does exist. On 29 March 2026 Rome jumps from 02:00 straight to 03:00, so a schedule set to 02:30 fires at 03:00 local, which is 01:00Z. Nothing about the gap is assumed to be an hour wide: Lord Howe Island moves by thirty minutes, and a 02:15 slot there fires at 02:30 local.

When several slots fall inside one gap they all point at the same instant, and EverTask fires once:

// On the transition day, 02:15 and 02:45 are the same instant. One occurrence, not two.
r.Schedule().OnDays(DayOfWeek.Sunday)
            .AtTimes(new TimeOnly(2, 15), new TimeOnly(2, 45))
            .InTimeZone("Europe/Rome");

When that happens the run says how many slots it stood for, at Information:

Task 6f1b… collapsed 2 nominal slot(s) into the occurrence at 2026-03-29T01:00:00+00:00:
a daylight-saving transition maps them to the same instant

That log line is the only place the number shows up. Everything else counts the occurrence, once: it spends one run of MaxRuns and calls the handler once.

A slot the clock repeated fires on the first pass. On 25 October 2026 Rome reads 02:30 twice, at 00:30Z and again at 01:30Z; the schedule fires at 00:30Z and skips the second reading. A handler that needs to tell the two apart can: Context.ScheduledAtLocal carries the offset.

An elapsed cadence goes through both untouched, which is what “every 30 minutes” ought to mean. Across the repeated hour Every(30).Minutes() fires four times, because that hour really does last two hours, and nothing is compressed inside the gap: 01:45 local plus thirty minutes is 03:15 local.

An Application-Wide Default

Applications whose schedules all belong to one zone can set it once:

services.AddEverTask(cfg => cfg
    .RegisterTasksFromAssembly(typeof(Program).Assembly)
    .SetDefaultScheduleTimeZone(TimeZoneInfo.FindSystemTimeZoneById("Europe/Rome")));

The default is applied when a schedule is built, to calendar-anchored schedules that did not call InTimeZone, and elapsed cadences are left untouched. An explicit InTimeZone always wins.

The zone the default picks is written into the definition, so a row keeps meaning what it meant when it was registered. Changing the default later affects new registrations; it does not silently move schedules that are already stored. To move those, re-register them under the same taskKey.

Cron Expressions

A cron expression takes the zone the same way, and the DST rules above apply to it too:

await dispatcher.Dispatch(
    new NightlyReportTask(),
    r => r.Schedule().UseCron("0 2 * * *").InTimeZone("America/New_York"),
    taskKey: "nightly-report");

Cron is evaluated by Cronos, which owns the transition rules for expressions. EverTask’s own fluent grid is tested against it in nine zones, so the two agree on where every occurrence falls.

What a Handler Sees

A delivery reports the zone it belongs to:

public class SendDailyDigestHandler : EverTaskHandler<SendDailyDigestTask>
{
    public override Task Handle(SendDailyDigestTask task, CancellationToken ct)
    {
        Context.TimeZoneId;       // "Europe/Rome", or null for a schedule with no zone
        Context.ScheduledAtUtc;   // 2026-07-02T07:00:00+00:00
        Context.ScheduledAtLocal; // 2026-07-02T09:00:00+02:00

        return Task.CompletedTask;
    }
}

ScheduledAtLocal keeps its offset, which is what distinguishes the two passes of a repeated hour. Both are null when the schedule carries no zone. See Execution Context for the rest of what a delivery knows about itself.

One-Shot Dispatches

Dispatch(task, DateTimeOffset) takes an absolute instant, and a DateTimeOffset already carries its own offset: it is a fully specified point in time, so there is nothing left for a zone to govern. InTimeZone exists for the recurring case, where “09:00” has to be re-read on the zone’s clock at every occurrence; a one-shot resolves its offset exactly once, when you build the value, and nothing can drift afterwards.

So there is no InTimeZone for one-shots, and none is missing. What you may have to do is build the instant from a wall-clock time yourself (“10:00 on 25 December, Rome time”), and the one rule there is to use the offset in force on that date:

var zone  = TimeZoneInfo.FindSystemTimeZoneById("Europe/Rome");
var local = new DateTime(2026, 12, 25, 10, 0, 0);
var when  = new DateTimeOffset(local, zone.GetUtcOffset(local));

await dispatcher.Dispatch(new SendGreetingTask(userId), when);

GetUtcOffset(local) answers with the offset that date actually has: +01:00 on 25 December, +02:00 on a July date. BaseUtcOffset is the zone’s standard offset regardless of the date, so it is an hour off for the whole daylight-saving season. TimeZoneInfo.ConvertTimeToUtc(local, zone) is an equivalent, sometimes clearer spelling of the same conversion.

Two edge cases the one-liner does not answer well: a local time the spring transition skipped (ConvertTimeToUtc throws, GetUtcOffset answers as if the time existed) and one the autumn clock repeats (GetUtcOffset silently picks one of the two readings). A dispatch overload that takes a local time and a zone and answers both the way the recurring path does is tracked in #64.

Next Steps


Copyright © 2026 Giampaolo Gabba. Distributed under the MIT License.

This site uses Just the Docs, a documentation theme for Jekyll.