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 job and its schedule are two things written in two places: the class, and the AddQuartz call that registers it. [QuartzJob] and [CronTrigger] put both on the class, and the source generator that ships inside Quartz.nupkg writes the registration — the same AddJob<T> and AddTrigger<T> calls you would have written, in a file you can open and read.

Nothing here is read at run time. There is no scanning, no Type.GetType, no reflection of any kind: the attributes are read by the compiler, and what reaches the scheduler is ordinary C#. A declared job is therefore exactly as trimmable and as native-AOT clean as a hand-written registration, and the repository's trimming canary declares one of its jobs this way to keep it that way.

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 anything the attributes cannot say is written beside it as it always was:

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 appears once something in the assembly carries [QuartzJob]; a project that declares no job gets no generated file and no method to call.

This is what the generator writes for the job above — one internal class per assembly, so two assemblies that both declare jobs never collide:

// <auto-generated/>
#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;
    }
}

A default is left off rather than spelled out, so what the file says is what the attributes asked for.

Tips

To read the file your own build produced rather than the one above, set <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> 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 [CronTrigger] because a non-durable job nothing points at is deleted as soon as it is stored: declaring one that vanishes cannot be what was meant. Give such a job its trigger later, from code or from a scheduling file.

What [CronTrigger] says

Write it once per schedule; a job with three of them 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

The first schedule a job declares is named after the job, because that is what a single trigger would have been called by hand. The second and later ones count up from it — cleanup, cleanup-2, cleanup-3 — and a Name of its own overrides that for one of them without renumbering the rest.

One scheduler out of several

AddDeclaredJobs() registers on the builder it is called on, so the simplest way to give a named scheduler its own declared jobs is to call it there — no attribute is involved at all:

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

When one assembly declares jobs for several schedulers, Scheduler on the job says which one it belongs to, and 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 — the unnamed scheduler included, whose name is the empty string. A job naming none is registered on whichever builders AddDeclaredJobs() is called on.

The compiler checks the cron

The expression on [CronTrigger] is read at build time by the parser that reads it at run time, so one that cannot parse is a build error rather than an exception while the host starts:

// 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, and generated code is not analysed. H is accepted here, because the schedule is built with WithCronSchedule, which resolves H against the trigger's key. Compile-Time Checks is the rest of what the analyzer reads.

What the generator refuses

Three more build errors, all of them cases where the alternative is a job that was declared and never fires.

QZ1001 DeclaredJobTypeNotSchedulable

[QuartzJob] on a type that AddJob<T> could not 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<TInput> implementer is fine, since it is an IJob.

QZ1002 DuplicateDeclaredIdentity

Two declarations resolving to one job key, or to one trigger key. A key is an identity: the second registration does not sit beside the first, it replaces it. Keys are compared within a scheduler, so the same key on two jobs that name different Schedulers is two jobs rather than a clash.

QZ1003 CronTriggerWithoutQuartzJob

[CronTrigger] on a class carrying no [QuartzJob]. The schedule is read as part of the job the other attribute declares, so on its own it registers nothing — and a schedule that silently registers nothing is worse than a build error.

What an attribute does not say

A declared job is a starting point, not a second configuration system. Everything below is still written as a registration, beside AddDeclaredJobs():

  • A start or end time, a calendar, job data, a retry policy, a preferred node. AddTrigger<T> says all of them, and a declared job can be given further triggers by hand — ForJob with the key the attribute declared is all it takes.
  • A schedule that is not cron. [SimpleTrigger] and the other trigger families are deliberately not here: cron is the schedule an attribute can carry without becoming a builder, and the rest are better written where the other trigger settings already are.
  • A cron expression from configuration. An attribute argument is a constant by definition, which is what lets the compiler check it. A schedule a deployment changes belongs in a scheduling file or the Quartz:Schedule section.
  • Jobs from another assembly. AddDeclaredJobs() is generated per assembly and registers that assembly's jobs. A library that declares jobs exposes its own registration call, or the application writes one.

Related

  • Compile-Time Checks — the four diagnostics the analyzer reports, QZ0001 among them
  • Cron Triggers and Cron Expressions — what the expression on [CronTrigger] may say
  • Using Quartz — the registration calls the generated file is written in terms of
Help us by improving this page!
Last Updated: 9/19/26, 9:30 PM
Contributors: Marko Lahma, Claude Opus 5 (1M context)
Prev
Compile-Time Checks