Quartz.NETQuartz.NET
Home
Features
Discussions
NuGet
GitHub
Home
Features
Discussions
NuGet
GitHub
  • Getting Started

    • Quartz 3 Quick Start
    • Tutorial
      • Using Quartz
      • Library Overview
      • Jobs And Triggers
      • More About Jobs
      • More About Triggers
      • Execution Groups
      • Node Affinity (Preferred Node)
      • Simple Triggers
      • Cron Triggers
      • RecurrenceTrigger
      • Trigger and Job Listeners
      • Scheduler Listeners
      • Job Stores
      • Tuning the Scheduler
      • Configuration, Resource Usage and SchedulerFactory
      • Advanced (Enterprise) Features
    • Configuration Reference
    • JSON Configuration
    • Multi-Tenancy
    • Frequently Asked Questions
    • Best Practices
    • Tenancy Patterns
    • Troubleshooting
    • API Documentation
    • Database Schema
    • Database Schema Changes
    • Migration Guide
    • Miscellaneous Features
  • How To's

    • One-Off Job
    • Multiple Triggers
    • Job Template
    • Using the CronTrigger
    • Rescheduling Jobs
  • Packages

    • Quartz Core Additions

      • Dashboard
      • Jobs
      • Serialization (System.Text.Json)
      • Serialization (Newtonsoft Json.NET)
      • Plugins
    • Integrations

      • ASP.NET Core Integration
      • Hosted Services Integration
      • Microsoft DI Integration
      • Multiple Schedulers with Microsoft DI
      • OpenTelemetry Integration
      • OpenTracing Integration
      • Redis Lock Handler
      • TimeZoneConverter Integration
    • 3rd Party Plugins for Quartz
  • Unreleased Releases

    • Quartz 4.x
      • Quartz 4 Quick Start
      • Tutorial
        • Using Quartz
        • Jobs And Triggers
        • More About Jobs & JobDetails
        • Job Data
        • More About Triggers
        • Querying Jobs and Triggers
        • Simple Triggers
        • Cron Triggers
        • RecurrenceTrigger
        • Time and TimeProvider
        • Trigger and Job Listeners
        • Scheduler Listeners
        • Job Stores
        • Configuration, Resource Usage and Building a Scheduler
        • Building a Scheduler Without a Host
        • Clustering
        • Execution Groups
        • Node Affinity (Preferred Node)
        • Testing
      • Configuration Reference
      • JSON Configuration
      • Cron Expression Reference
      • Multi-Tenancy
      • Frequently Asked Questions
      • Best Practices
      • Tenancy Patterns
      • Database Schema
      • Database Schema Changes
      • Migration Guide
      • Troubleshooting
      • API Documentation
      • How To's
        • One-Off Job
        • Rescheduling Jobs
        • Multiple Triggers
        • Job Template
        • A Job Store of Your Own
        • A Driver Delegate for a New Database
        • Persisting a Custom Trigger Type
        • A Lock Handler of Your Own
      • Packages

        • Quartz Core Additions

          • Jobs
          • Serialization (System.Text.Json)
          • JSON Serialization
          • Plugins
        • Integrations

          • ASP.NET Core Integration
          • HTTP API
          • HTTP Client
          • Dashboard
          • Hosted Services Integration
          • Microsoft DI Integration
          • Multiple Schedulers with Microsoft DI
          • Observability
          • Redis Lock Handler
          • TimeZoneConverter Integration
        • 3rd Party Plugins for Quartz
  • Old Releases

    • Quartz 2.x
      • Quartz 2 Quick Start
      • Tutorial
        • Lesson 1: Using Quartz
        • Lesson 2: Jobs And Triggers
        • Lesson 3: More About Jobs & JobDetails
        • Lesson 4: More About Triggers
        • Lesson 5: SimpleTrigger
        • Lesson 6: CronTrigger
        • Lesson 7: TriggerListeners and JobListeners
        • Lesson 8: SchedulerListeners
        • Lesson 9: JobStores
        • Lesson 10: Configuration, Resource Usage and SchedulerFactory
        • Lesson 11: Advanced (Enterprise) Features
        • Lesson 12: Miscellaneous Features of Quartz
        • CronTrigger Tutorial
      • Configuration Reference
      • Migration Guide
      • API Documentation
    • Quartz 1.x
      • Tutorial
        • Lesson 1: Using Quartz
        • Lesson 2: Jobs And Triggers
        • Lesson 3: More About Jobs & JobDetails
        • Lesson 4: More About Triggers
        • Lesson 5: SimpleTrigger
        • Lesson 6: CronTrigger
        • Lesson 7: TriggerListeners and JobListeners
        • Lesson 8: SchedulerListeners
        • Lesson 9: JobStores
        • Lesson 10: Configuration, Resource Usage and SchedulerFactory
        • Lesson 11: Advanced (Enterprise) Features
        • Lesson 12: Miscellaneous Features of Quartz
      • API Documentation
  • License

A scheduler is a machine for asking "what time is it?" — thousands of times an hour, from the scheduling loop, from every trigger's fire-time computation, from the misfire handler and the cluster check-in. In 4.x there is exactly one place that question is answered: a TimeProvider, injected like any other service.

SystemTime is gone

3.x had SystemTime.UtcNow, a mutable static Func<DateTimeOffset> you assigned to. It worked, it was global, and two tests that both wanted a fake clock could not run at the same time.

TimeProvider is the .NET-standard replacement, and Quartz treats it as a per-scheduler service. Nothing is assigned; the clock is injected, and one scheduler's fake clock is not another's.

Tips

DateTime.Now, DateTime.Today, DateTimeOffset.Now, DateTimeOffset.Today and the implicit DateTime → DateTimeOffset conversion are banned in the Quartz codebase by an analyzer. UtcNow is not banned — it is the ambient clock's correct answer where there is no scheduler to ask. In your own jobs the same discipline pays off for the same reason: a job that reads DateTime.Now cannot be tested on a fake clock.

Setting the clock

One call, on either builder:

builder.Services.AddQuartz(q =>
{
    q.UseTimeProvider(myTimeProvider);
});
IScheduler scheduler = await QuartzSchedulerBuilder.Create()
    .UseTimeProvider(myTimeProvider)
    .BuildScheduler();

Precedence

Four sources, most specific first:

  1. UseTimeProvider(...) on this scheduler. Wins over everything.
  2. A TimeProvider registered in the container. A named scheduler with no clock of its own inherits the application's.
  3. The legacy quartz.timeProvider.type property key. Code beats strings here as it does everywhere else.
  4. TimeProvider.System.

The container-wide default is registered with TryAddSingleton, so an application that already does services.AddSingleton(TimeProvider.System) — or registers a test clock — keeps its own registration and every scheduler picks it up.

UseTimeProvider on a named scheduler registers the clock keyed by that scheduler's name; on the default scheduler it replaces the container's unkeyed TimeProvider. That asymmetry is deliberate: a container-wide replacement would re-time every other scheduler in the process, and a test that hands one scheduler a fake clock does not mean the others should start lying too.

How far the clock reaches

Everything the container builds for a scheduler gets that scheduler's clock:

ComponentWhat it uses the clock for
QuartzScheduler / the scheduling loopdeciding whether a trigger is due, StartDelayed
RAMJobStorefire times, misfire detection
AdoJobStoreBase and its subclassesthe same, plus retry backoff
IDriverDelegate (via DriverDelegateContext.TimeProvider)timestamps written to the database
ISemaphore (via SemaphoreContext.TimeProvider)lock-acquisition backoff
MisfireHandlerits scan interval
ClusterManagercheck-in interval and failed-node detection

A custom job store, driver delegate or lock handler joins that list simply by taking a TimeProvider constructor parameter, or by reading the one its context carries — it is resolved for the scheduler the component belongs to.

Builders and the clock

Exactly one builder takes a clock:

TriggerBuilder.Create(TimeProvider? timeProvider = null);
TriggerBuilder.Create<TJob>(TimeProvider? timeProvider = null);

Inside the builder it does three things: it is the default StartTimeUtc when you do not call StartAt, it is what StartNow() reads, and it is handed to the schedule builder at Build() time so that a schedule computed there — DailyTimeIntervalScheduleBuilder.EndingDailyAfterCount(n) is the one that does this — is computed against the same clock.

The five schedule builders take no clock:

CronScheduleBuilder.Create(cronExpression);
SimpleScheduleBuilder.Create();
CalendarIntervalScheduleBuilder.Create();
DailyTimeIntervalScheduleBuilder.Create();
RecurrenceScheduleBuilder.Create(recurrenceRule);

They describe a shape — every day at 09:00, every 15 minutes, the third Tuesday — and a shape needs no clock. When one of them does need "now", it gets it from the trigger builder.

DateBuilder has two statics, both taking an optional clock:

DateTimeOffset when = DateBuilder.Create(timeProvider).InYear(2027).InMonthOnDay(3, 15).AtHourOfDay(9).Build();
DateTimeOffset local = DateBuilder.CreateInTimeZone(tz, timeProvider).AtHourMinuteAndSecond(9, 30, 0).Build();

Changed in 4.x

DateBuilder.NewDate / NewDateInTimeZone are now Create / CreateInTimeZone, and CronScheduleBuilder.CronSchedule(...) is CronScheduleBuilder.Create(...) — the whole family follows the Create factory convention now. DailyTimeIntervalScheduleBuilder.Create() also lost its TimeProvider parameter; it takes the trigger builder's clock instead, which is what makes EndingDailyAfterCount respect a fake clock.

The trap: triggers built outside the container

The DI configuration path threads the container's clock through for you. Both AddTrigger<TJob> and ScheduleJob<T> create their builder as TriggerBuilder.Create<TJob>(serviceProvider.GetService<TimeProvider>()), so a trigger configured there starts on the scheduler's clock:

builder.Services.AddQuartz(q =>
{
    q.UseTimeProvider(fakeClock);

    // this trigger's implicit start time is the fake clock's now
    q.AddTrigger<ReportJob>(t => t
        .WithSimpleSchedule(s => s.WithInterval(TimeSpan.FromHours(1)).RepeatForever()));
});

A trigger you build yourself does not:

// StartTimeUtc is the WALL CLOCK, whatever the scheduler's TimeProvider says
ITrigger trigger = TriggerBuilder.Create()
    .WithIdentity("hourly")
    .WithSimpleSchedule(s => s.WithInterval(TimeSpan.FromHours(1)).RepeatForever())
    .Build();

This is the single most common surprise in a fake-clock test: the scheduler is on 2024-01-01, the trigger says it starts now, and now is whenever the test ran. Pass the clock:

ITrigger trigger = TriggerBuilder.Create(fakeClock)
    .WithIdentity("hourly")
    .StartAt(fakeClock.GetUtcNow())
    .WithSimpleSchedule(s => s.WithInterval(TimeSpan.FromHours(1)).RepeatForever())
    .Build();

An explicit StartAt sidesteps the question entirely, which is why it is worth being explicit in tests even when you would not bother in production code.

Warning

Constructing a trigger implementation directly — new SimpleTriggerImpl(timeProvider), new CronTriggerImpl(name, group, expression, timeProvider) and their siblings — takes a TimeProvider? that defaults to TimeProvider.System. The parameter is also not serialized: a trigger read back out of a job store reads the system clock until the store hands it one.

Time zones are a separate axis

TimeProvider answers what instant is it. TimeZoneInfo answers what does that instant look like where the schedule lives. They are independent, and a fake clock does not fake a time zone.

TriggerBuilder.Create()
    .WithCronSchedule("0 0 9 * * ?", x => x.InTimeZone(TimeZones.FindById("Europe/Helsinki")))
    .Build();

TimeZones has three members:

  • FindById(string id) — the lookup to use instead of TimeZoneInfo.FindSystemTimeZoneById. It tries the platform first, then a built-in alias table (UTC, CET, US/Eastern and friends), then IANA-to-Windows conversion, then any registered resolver. The platform lookup goes first on purpose: converting an id up front would rewrite US/Eastern into Eastern Standard Time, and it is the rewritten id a job store would write back into TIME_ZONE_ID.
  • GetUtcOffset(DateTime, TimeZoneInfo) — the offset, resolving an ambiguous (repeated) local time to the daylight instance, because that is the first of the two.
  • AddResolver(Func<string, TimeZoneInfo?>) — registers a fallback lookup and returns an IDisposable that removes it. Resolvers are consulted most-recently-added first, and this is process-wide: FindById is reached from places with no scheduler in scope, such as parsing a cron expression or deserializing a trigger out of a blob. Quartz.Plugins.TimeZoneConverter installs one and disposes it at scheduler shutdown.

Changed in 4.x

TimeZoneUtil is now TimeZones, and CustomResolver is AddResolver, which returns a registration you dispose rather than a property you assign.

Daylight saving is where the two axes meet, and each trigger family answers it differently:

  • CronTriggers — a cron time that does not exist on a spring-forward day, and one that happens twice on a fall-back day
  • More About Triggers — calendar-interval triggers, PreserveHourOfDayAcrossDaylightSavings and SkipDayIfHourDoesNotExist

Testing with a fake clock

Microsoft.Extensions.TimeProvider.Testing gives you FakeTimeProvider:

FakeTimeProvider clock = new(new DateTimeOffset(2026, 3, 1, 0, 0, 0, TimeSpan.Zero));

builder.Services.AddQuartz(q => q.UseTimeProvider(clock));

Read this before you rely on it: advancing a fake clock changes what the scheduler computes, but it does not wake the scheduler. The scheduling loop's idle wait and its pre-fire wait are SemaphoreSlim waits on the real clock — SemaphoreSlim.WaitAsync has no TimeProvider overload — so clock.Advance(TimeSpan.FromHours(1)) does not make a trigger fire. The same is true of the misfire handler's and the cluster manager's scan intervals, which do run on the TimeProvider but only wake when their own real delay elapses.

What the fake clock does drive: every fire-time computation, misfire detection, StartDelayed, and the retry and backoff delays in the ADO store.

The Testing page turns that into a rule — advance, then signal — and leads with the level where a fake clock is completely effective: computing fire times with no scheduler at all.

Legacy: the property key

quartz.timeProvider.type still works, and names a type with a parameterless constructor:

quartz.timeProvider.type = MyApp.TestClock, MyApp

It is registered with TryAdd semantics, which is exactly what makes UseTimeProvider win: the configuration callback runs first. It does forcibly displace Quartz's own TimeProvider.System fallback, though — a key that was read and then quietly ignored is the one outcome no configuration key is allowed to have.

See also

  • Testing — the fake-clock rules, and the three levels of Quartz test
  • Configuration Reference — IdleWaitTime, misfire thresholds and the rest
  • CronTriggers — time zones and DST in cron schedules
Help us by improving this page!
Last Updated: 8/23/26, 6:42 AM
Contributors: Marko Lahma
Prev
RecurrenceTrigger
Next
Trigger and Job Listeners