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

    • Overview
    • 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 Execution Middleware
      • Job Stores
      • Configuration, Resource Usage and Building a Scheduler
      • Building a Scheduler Without a Host
      • Clustering
      • Execution Groups
      • Node Affinity (Preferred Node)
      • Testing
      • Compile-Time Checks
      • Declaring Jobs with Attributes
    • Configuration Reference
    • JSON Configuration
    • Cron Expression Reference
    • Multi-Tenancy
    • Comparison
    • Frequently Asked Questions
    • Best Practices
    • Before You Go Live
    • Operating a Cluster
    • Log Events
    • Tenancy Patterns
    • Database Schema
    • Database Schema Changes
    • Migration Guide
    • Troubleshooting
    • API Documentation
  • How To's
    • One-Off Job
    • Rescheduling Jobs
    • Retrying Failed Jobs
    • Job Continuations
    • Multiple Triggers
    • Job Template
    • Running Quartz under Aspire
    • Quartz.NET with Wolverine
    • Coming from Hangfire
    • Coming from TickerQ
    • Embedding Quartz in a Library
    • Running under an External Leader Election
    • Publishing Trimmed and Native AOT
    • Extending Quartz: what is open, what is closed, and how to ask
    • 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

      • Aspire Integration
      • 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
  • Quartz 3.x

    • 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
  • 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

Version compatibility

This documentation relates to Quartz version 4.2 and later.

A cron expression that cannot parse is an exception, and the question is only how long you wait for it. AddQuartz raises one while the host is starting; ScheduleJob raises one when the code that schedules finally runs; [JobTimeout("5 minutes")] raises one the first time the timeout middleware reflects over the job, which is after deployment.

Quartz ships an analyzer that asks the same questions at build time. It is inside the package — under analyzers/dotnet/cs — so referencing Quartz is all there is to do:

<PackageReference Include="Quartz" Version="4.2.0" />

The analyzer reads cron with the very parser Quartz reads it with at run time. It is not a smaller reimplementation: H, wrapping ranges, L, W, #, the @ macros and the five-field Unix dialect all mean here exactly what they mean there, and the sentence the compiler prints is the sentence the exception would have carried.

What it checks

IdSeverityWhat it says
QZ0001ErrorA cron literal or constant that the parser refuses
QZ0002ErrorA [JobTimeout] argument that is not a TimeSpan, or is negative
QZ0003WarningA job that persists its data map and allows concurrent firings
QZ0004InfoA job body that awaits or loops without reading its cancellation token

Only values the compiler already knows are read: a string literal, a const, an interpolated string with nothing interpolated into it. An expression built at run time, read from configuration or passed in a variable is left alone — silently, because an analyzer that guessed would be worse than one that did not look.

QZ0001 InvalidCronExpression

Reported at every call that takes a cron expression string: CronScheduleBuilder.Create, WithCronSchedule, the CronExpression constructors, CronExpression.Parse, TryParse, ParseWithHash, TryParseWithHash and ResolveHash, the CronCalendar constructors and CronTriggerImpl's — and at the one entry point that is not a call, the [CronTrigger] attribute a job declares its schedule with.

// error QZ0001: '0 0 12 * *' is not a valid cron expression: ... has 5 fields, but 6 or 7 are
// required: seconds, minutes, hours, day-of-month, month, day-of-week, and optionally year.
_ = TriggerBuilder.Create().WithCronSchedule("0 0 12 * *").Build();

Two things decide how a literal is read, and both come from the call rather than from the string:

  • The dialect. A sibling CronFormat argument is honoured, so CronExpression.Parse("30 4 * * 1", CronFormat.Unix) is a valid crontab line and the same five fields without it are not. A CronFormat the compiler cannot evaluate skips the check entirely: the same digits mean different days in each dialect, so there is nothing to check rather than something to guess.
  • Whether H is resolved. WithCronSchedule("0 H 3 * * ?") is valid, because the builder resolves the hash against the trigger key later; CronExpression.Parse("0 H 3 * * ?") is not, because it has no key to resolve it against. The analyzer reports exactly what each entry point would have thrown.

TryParse is checked too. Its contract is that an expression might not parse; a literal that can never parse is still a bug, and a TryParse that can only return false is not what anybody wrote.

QZ0002 InvalidJobTimeout

[JobTimeout] takes its budget as an invariant TimeSpan string, because an attribute argument cannot be a TimeSpan. The constructor parses it, and nothing calls that constructor until the timeout middleware reflects over the job type.

[JobTimeout("5 minutes")] // error QZ0002: '5 minutes' is not a TimeSpan. Spell the job's timeout the
public class ReportJob : IJob // way TimeSpan does, invariantly: "00:05:00" for five minutes.

A negative budget is reported too. "00:00:00" is not: zero is how a job says it has no timeout, whatever the scheduler's default is.

QZ0003 PersistJobDataWithoutDisallowConcurrent

[PersistJobDataAfterExecution] re-stores the job data map when a firing completes. With concurrent firings allowed, two of them read the same map, change it independently and store it one after the other — so whatever the first wrote is gone, and nothing anywhere says so.

[PersistJobDataAfterExecution]
[DisallowConcurrentExecution] // without this line: warning QZ0003
public class CounterJob : IJob

Both attributes are read the way Quartz reads them at run time: the type's own declaration, a base class it inherits from, or any interface it implements. So a job that takes [DisallowConcurrentExecution] from a contract it fulfils is not reported, and a job that inherits [PersistJobDataAfterExecution] from one is.

QZ0004 CancellationTokenNotObserved

The token IJob.Execute is handed is the one the scheduler cancels on shutdown and on IScheduler.Interrupt. A body that awaits or loops and never reads it — neither the parameter nor IJobExecutionContext.CancellationToken, which is the same token — runs to completion whatever the scheduler asks of it.

public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default)
{
    await Task.Delay(TimeSpan.FromMinutes(5)); // info QZ0004
}

This is information rather than a warning, and deliberately. Whether a piece of work is interruptible is a judgement no analyzer can make, and a job that returns in a millisecond is right to ignore the token. The await-or-loop condition is what keeps it off the jobs that could not honour a cancellation anyway. CA2016, which the .NET SDK ships, covers the neighbouring case: a token that exists and is not forwarded to a call that would take one.

Changing a severity, or turning it off

Each diagnostic is an ordinary compiler diagnostic, so .editorconfig moves it:

[*.cs]
# A cron literal that cannot parse is only a warning here
dotnet_diagnostic.QZ0001.severity = warning

# This codebase does not want the cancellation-token hint
dotnet_diagnostic.QZ0004.severity = none

To take the analyzer out of the build entirely, exclude the analyzer assets from the package reference:

<PackageReference Include="Quartz" Version="4.2.0" ExcludeAssets="analyzers" />

Tips

ExcludeAssets="analyzers" is a whole-package switch and affects nothing else Quartz does. Prefer the .editorconfig route when what you want is one rule quieter rather than all four gone.

What it does not check

Some things look checkable and are not, and each is left out for a reason rather than for want of time.

  • RRULE literals (RecurrenceScheduleBuilder.Create). The recurrence parser is not BCL-clean, so it cannot be linked into an assembly the compiler loads.
  • Time-zone ids (TimeZones.FindById). The build machine's zone database is not the run-time machine's, and an analyzer that refused a zone your server has would be worse than no analyzer.
  • Retry policies, preferred nodes, key names and type-name strings. Each is either rare in code, refused by name at startup, or already [RequiresUnreferencedCode].

Related

The analyzer half of the 4.2 compile-time work is this page. The other half — attributes that declare a job's schedule where the job is written, and a source generator that registers them — is Declaring Jobs with Attributes, and ships in the same assembly. Its three diagnostics are QZ1001, QZ1002 and QZ1003; the cron expression an attribute carries is read by QZ0001 above.

Help us by improving this page!
Last Updated: 9/19/26, 9:30 PM
Contributors: Marko Lahma, Claude Opus 5 (1M context)
Prev
Testing
Next
Declaring Jobs with Attributes