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
      • Delegate Jobs
    • 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
    • Backfill
    • Retrying Failed Jobs
    • Pausing with a Reason
    • Job Continuations
    • Overlap Policy
    • Progress and Execution Logs
    • Job Outcomes
    • 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
      • Weasel Schema Management
    • 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.

[QuartzJob] declares a job on its class; [CronTrigger] and [SimpleTrigger] (4.3) declare its schedules. The source generator inside Quartz.nupkg writes the registration: the same AddJob and AddTrigger calls you would have written, in a file you can open and read.

Nothing is read at run time: no scanning, no Type.GetType, no reflection. The compiler reads the attributes and the scheduler gets ordinary C#, so a declared job is as trimmable and native-AOT clean as a hand-written registration. The repository's trimming canary declares one of its jobs this way to keep it so.

Declaring a job

[QuartzJob(Name = "cleanup", Group = "maintenance", Description = "removes rows nobody reads")]
[CronTrigger("0 0 0/6 * * ?")]
[CronTrigger("0 0 12 ? * MON-FRI", Name = "cleanup-weekday-noon", TimeZone = "Europe/Helsinki")]
public sealed class CleanupJob : IJob
{
    public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default)
    {
        return default;
    }
}

Registering what was declared

AddDeclaredJobs() adds every job the current assembly declares. It is an ordinary registration call, so write anything the attributes cannot say beside it:

services.AddQuartz(q =>
{
    // Every job in this assembly that carries [QuartzJob], with the schedules it declares.
    q.AddDeclaredJobs();

    // Anything an attribute cannot say is still written here, beside it.
    q.AddTrigger<CleanupJob>(trigger => trigger
        .WithIdentity("cleanup-on-start")
        .ForJob("cleanup", "maintenance")
        .StartNow());
});

services.AddQuartzHostedService();

The method exists once something in the assembly carries [QuartzJob]; a project that declares no job gets no generated file and no method.

The generator writes one internal class per assembly, so nothing reaches the assembly's public surface. Two assemblies that declare jobs each get their own; only InternalsVisibleTo puts both in scope at once (see QZ1004). For the job above it writes:

// 
#nullable enable

namespace Quartz
{
    internal static class QuartzDeclaredJobs
    {
        public static global::Quartz.IQuartzBuilder AddDeclaredJobs(this global::Quartz.IQuartzBuilder builder)
        {
            builder.AddJob<global::MyApp.CleanupJob>(job => job
                .WithIdentity("cleanup", "maintenance")
                .WithDescription("removes rows nobody reads"));

            builder.AddTrigger<global::MyApp.CleanupJob>(trigger => trigger
                .WithIdentity("cleanup", "maintenance")
                .ForJob("cleanup", "maintenance")
                .WithCronSchedule("0 0 0/6 * * ?"));

            builder.AddTrigger<global::MyApp.CleanupJob>(trigger => trigger
                .WithIdentity("cleanup-weekday-noon", "maintenance")
                .ForJob("cleanup", "maintenance")
                .WithCronSchedule("0 0 12 ? * MON-FRI", cron => cron
                    .InTimeZone(global::Quartz.TimeZones.FindById("Europe/Helsinki"))));

            return builder;
        }

        public static global::Quartz.IQuartzBuilder AddDeclaredJobsFromMyApp(this global::Quartz.IQuartzBuilder builder)
        {
            return AddDeclaredJobs(builder);
        }
    }
}
  • Defaults are left out, so the file says only what the attributes asked for.
  • AddDeclaredJobsFrom() (4.3) is the same registration under the assembly's own name, which binds however many other assemblies' registrations are visible; see QZ1004.
  • The file is plain C# 8 (a block namespace, not file-scoped), so a project pinned to an older LangVersion compiles it. The attributes' named properties are init-only, so setting one needs C# 9: a C# 8 project can write [QuartzJob] and [CronTrigger("…")] with no named properties.

Tips

To read the file your own build produced, set true and look in obj/…/generated/Quartz.Analyzers/Quartz.Analyzers.DeclaredJobsGenerator/QuartzDeclaredJobs.g.cs.

What [QuartzJob] says

PropertyDefaultWhat it sets
Namethe class's own namethe job key's name
GroupDEFAULTthe job key's group
Descriptionnonethe description carried on the job detail
Durablefalse, and forced true for a job that declares no schedulewhether the job stays in the store when no trigger points at it
RequestRecoveryfalsewhether a firing interrupted by a hard shutdown is re-fired on recovery
Schedulerevery schedulerthe one scheduler this job belongs to — see One scheduler out of several

Durable is forced on for a job with no schedule, because a non-durable job with no trigger is deleted as soon as it is stored. Give such a job its trigger later, from code or a scheduling file.

What [CronTrigger] says

Write one per schedule; a job with three gets three triggers.

PropertyDefaultWhat it sets
the constructor argument—the cron expression, in Quartz's six- or seven-field form
Namethe job's name, then -2, -3 …the trigger key's name
Groupthe job's groupthe trigger key's group
TimeZonethe scheduler's local zonethe zone the schedule is read in, by the id TimeZones.FindById resolves — IANA or Windows
MisfireInstructionSmartPolicywhat the trigger does about a firing it missed
Priority5who wins when two triggers want the same moment and one worker is free
Descriptionnonethe description carried on the trigger
ExecutionGroupnonethe execution group the firing counts against
ConfigurationKey (4.3)nonea configuration key whose value replaces the expression — see A schedule from configuration

The first schedule is named after the job, as a single hand-written trigger would be. Later ones count up: cleanup, cleanup-2, cleanup-3. A Name of its own overrides one without renumbering the rest. [CronTrigger] and [SimpleTrigger] count together, in the order they are written.

What [SimpleTrigger] says

[SimpleTrigger] (4.3) is a fixed interval: a simple trigger with no start time. Write one per schedule, beside any [CronTrigger]:

// Fires as the scheduler starts, then every ten minutes. Jobs:Inbox:Interval, when it is set, replaces
// the ten minutes; the warm-up polls four times, five seconds apart, and stops.
[QuartzJob(Name = "poll-inbox")]
[SimpleTrigger("00:10:00", ConfigurationKey = "Jobs:Inbox:Interval")]
[SimpleTrigger("00:00:05", Name = "poll-inbox-warm-up", RepeatCount = 3)]
public sealed class PollInboxJob : IJob
{
    public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default)
    {
        return default;
    }
}
PropertyDefaultWhat it sets
the constructor argument—the interval, as an invariant TimeSpan string: "00:10:00" is ten minutes, "1.00:00:00" a day
RepeatCount-1, foreverhow many times it repeats after the first firing
Namethe job's name, then -2, -3 …the trigger key's name
Groupthe job's groupthe trigger key's group
MisfireInstructionSmartPolicya SimpleTriggerMisfireInstruction: what the trigger does about a firing it missed
Priority5who wins when two triggers want the same moment and one worker is free
Descriptionnonethe description carried on the trigger
ExecutionGroupnonethe execution group the firing counts against
ConfigurationKeynonea configuration key whose value replaces the interval — see A schedule from configuration
  • It fires as the scheduler starts. Like an AddTrigger without StartAt, the trigger starts when the scheduler is built. RepeatCount = 0 is that one firing.
  • The interval is parsed at build time and written into the registration as ticks, so nothing parses it at run time. One that is not a positive TimeSpan, and a RepeatCount below -1, are QZ0005.

A schedule from configuration

ConfigurationKey (4.3) reads the expression, or on [SimpleTrigger] the interval, from the container's IConfiguration as the scheduler is built. The constructor's value is the fallback:

// Jobs:Report:Cron in appsettings.json, or Jobs__Report__Cron in the environment, replaces the
// expression. Without it, the report runs at 06:00.
[QuartzJob(Name = "report")]
[CronTrigger("0 0 6 * * ?", ConfigurationKey = "Jobs:Report:Cron")]
public sealed class DailyReportJob : IJob
{
    public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default)
    {
        return default;
    }
}
{
  "Jobs": {
    "Report": {
      "Cron": "0 30 5 * * ?"
    }
  }
}
KeySchedule
setthe configured value
not set, or no IConfiguration registeredthe attribute's expression or interval
set to a value the parser refuses, empty includednone: building the scheduler throws FormatException, so the host does not start
  • A configured interval is an invariant TimeSpan string, as the attribute's is: "00:05:00". One that does not parse, or is not positive, throws a FormatException naming the key.
  • The attribute's own value is still required and still checked, by QZ0001 or QZ0005.
  • A configured value is checked only at run time, when the scheduler is built.
  • Only the expression or interval comes from configuration; Name, TimeZone, RepeatCount and the rest stay the attribute's.
  • The generated registration reads it through (services, trigger) => …, the same lookup a hand-written one makes. Nothing is reflected, so it stays trimming- and AOT-safe.
  • An assembly that does not reference Microsoft.Extensions.Configuration.Abstractions cannot read the key: QZ1005.

One scheduler out of several

AddDeclaredJobs() registers on the builder it is called on. To give a named scheduler its declared jobs, call it there; no attribute is needed:

builder.Services.AddQuartz("reporting", q => q.AddDeclaredJobs());

When one assembly declares jobs for several schedulers, set Scheduler on the job. The generated registration is wrapped in a check on the builder's name:

[QuartzJob(Name = "nightly-report", Scheduler = "reporting")]
[CronTrigger("0 0 6 * * ?")]
public sealed class ReportJob : IJob { /* … */ }
// generated
if (builder.SchedulerName == "reporting")
{
    builder.AddJob<global::MyApp.ReportJob>(job => job.WithIdentity("nightly-report"));
    // …
}
  • A job naming a scheduler is skipped by every other one, including the unnamed scheduler, whose name is the empty string.
  • A job naming none is registered on every builder AddDeclaredJobs() is called on.

The compiler checks the cron

The expression on [CronTrigger] is read at build time by the run-time parser, so one that cannot parse is a build error, not an exception at host start:

// 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.
[QuartzJob]
[CronTrigger("0 0 12 * *")]
public sealed class CleanupJob : IJob { /* … */ }
  • It is reported once, on the attribute. The generated file carries the same literal, but generated code is not analysed.
  • A missing expression, [CronTrigger(null!)] or [CronTrigger("")], is reported the same way, and the generator writes no schedule for it.
  • H is accepted, because the schedule is built with WithCronSchedule, which resolves H against the trigger's key.

[SimpleTrigger]'s interval and RepeatCount are checked the same way, by QZ0005, and a schedule it reports is not generated either.

Compile-Time Checks has the rest of what the analyzer checks.

What the generator reports

Four build errors, each for a job or schedule that would otherwise be declared and never used, and one note about a name. DisableQuartzAnalyzers removes the generator and these diagnostics with it; see Changing a severity, or turning it off.

QZ1001 DeclaredJobTypeNotSchedulable

Reports [QuartzJob] on a type AddJob cannot take: one that does not implement IJob, is abstract, is generic, or cannot be named from another file in the assembly (a private nested class, or a file-local one). An IJob implementer is fine; it is an IJob.

Fix the type so it qualifies, or remove the attribute.

QZ1002 DuplicateDeclaredIdentity

Reports two declarations that resolve to one job key or one trigger key. A key is an identity: the second registration replaces the first. Keys are compared within a scheduler, so the same key on two jobs naming different Schedulers is two jobs, not a clash.

Fix by giving one of them its own Name or Group.

QZ1003 CronTriggerWithoutQuartzJob

Reports [CronTrigger] or [SimpleTrigger] on a class with no [QuartzJob], once per class. The schedule is read as part of the job [QuartzJob] declares, so on its own it would silently register nothing.

Fix by adding [QuartzJob] to the class.

QZ1004 DeclaredJobsRegistrationRenamed

Reports, as information (a warning before 4.3), an assembly whose registration was renamed. When an assembly that declares jobs grants InternalsVisibleTo to another that declares jobs, both generated QuartzDeclaredJobs classes are in scope in the second. AddDeclaredJobs() there would be ambiguous, and naming the class would be too. So the second assembly's registration is named after the assembly: in MyApp.Worker it is QuartzDeclaredJobs_MyApp_Worker.AddDeclaredJobsFromMyApp_Worker(). Characters an identifier cannot hold become _, and a name starting with a digit gets a leading _.

AddDeclaredJobs() in that assembly still means the other assembly's jobs. The note, on the assembly's first [QuartzJob], says so:

info QZ1004: AddDeclaredJobs() in this assembly resolves to 'MyApp.Jobs''s declared jobs, which are
visible through InternalsVisibleTo; call AddDeclaredJobsFromMyApp_Worker() for this assembly's own

From 4.3 every registration also has the method named after its assembly, so each set of declared jobs has a spelling that binds:

Visible in the applicationCall
its own registration onlyAddDeclaredJobs(), or AddDeclaredJobsFrom()
its own and one library'sAddDeclaredJobsFrom() for its own; AddDeclaredJobs() or AddDeclaredJobsFrom() for the library's
two libraries'AddDeclaredJobsFrom() for each; AddDeclaredJobs() is ambiguous

Fix: nothing is broken. Where more than one registration is visible, call each by its assembly's name. The note is reported whatever the calls say; dotnet_diagnostic.QZ1004.severity = none hides it. An assembly no InternalsVisibleTo names sees no other registration and keeps QuartzDeclaredJobs.AddDeclaredJobs().

QZ1005 ConfigurationKeyWithoutConfiguration

Reports a [CronTrigger] or [SimpleTrigger] with a ConfigurationKey in an assembly that does not reference Microsoft.Extensions.Configuration.Abstractions. The generated registration reads the key through IConfiguration, so without the type the key could never be read, and the schedule would silently be the attribute's.

Fix by referencing the package (the Quartz package brings it, unless its assets are excluded), or by removing ConfigurationKey.

What an attribute does not say

Write these as registrations beside AddDeclaredJobs():

  • A start or end time, a calendar, job data, a preferred node. Use AddTrigger. A retry policy is [RetryPolicy] on the class. To give a declared job more triggers, use ForJob with the key the attribute declared.
  • A calendar-interval, daily-time-interval or recurrence schedule. Cron and a fixed interval are the schedules an attribute can carry without becoming a builder.
  • Anything but the expression or interval from configuration. ConfigurationKey replaces that alone. Put a whole schedule a deployment changes in a scheduling file or the Quartz:Schedule section.
  • Jobs from another assembly. AddDeclaredJobs() is generated per assembly, registers that assembly's jobs, and is internal. Without InternalsVisibleTo, an application cannot see a library's generated method, so the library exposes a registration call of its own, or the application writes one. With InternalsVisibleTo, the application calls the library's AddDeclaredJobsFrom(); QZ1004 has the table.

Related

  • Compile-Time Checks — the five diagnostics the analyzer reports, QZ0001 and QZ0005 among them
  • Cron Triggers and Cron Expressions — what the expression on [CronTrigger] may say
  • Simple Triggers — the trigger [SimpleTrigger] declares
  • Using Quartz — the registration calls the generated file is written in terms of
Help us by improving this page!
Last Updated: 10/9/26, 10:00 AM
Contributors: Marko Lahma, Claude Fable 5.1
Prev
Compile-Time Checks
Next
Delegate Jobs