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

A job class holds the code that does the work. The JobDetail, built with JobBuilder, holds the settings of one instance of that job. The scheduling code from Lesson 1:

// define the job and tie it to our HelloJob class
IJobDetail job = JobBuilder.Create<HelloJob>()
    .WithIdentity("myJob", "group1")
    .Build();

// Trigger the job to run now, and then every 40 seconds
ITrigger trigger = TriggerBuilder.Create()
    .WithIdentity("myTrigger", "group1")
    .StartNow()
    .WithSimpleSchedule(x => x
        .WithInterval(TimeSpan.FromSeconds(40))
        .RepeatForever())
    .Build();

await scheduler.ScheduleJob(job, trigger);

and the job class HelloJob:

public class HelloJob : IJob
{
    public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default)
    {
        await Console.Out.WriteLineAsync("HelloJob is executing.");
    }
}

The scheduler is given an IJobDetail that names the job's class. Every time it runs the job, it creates a new instance of the class and calls Execute.

  • Under AddQuartz the instance comes from the service container, in its own scope, so the job takes its dependencies through its constructor.
  • Do not keep state in fields on the job class. The instance is gone when the fire ends.
  • A registered job may not take a part that belongs to one scheduler by constructor: IScheduler, ISchedulerFactory, IJobStore, IThreadPool or IOptions<QuartzSchedulerOptions>. The container does not know which scheduler is firing the job, so startup refuses such a constructor. Use context.Scheduler; see which scheduler's parts a job is built from.

Configuration for a job instance, and state kept between executions, both go in the JobDataMap.

JobDataMap

The JobDataMap holds any number of (serializable) objects for the job to read when it runs. It implements IDictionary<string, object?>. Typed accessors (GetString, GetInt, GetDateTimeOffset, Get<T>, TryGet<T> and the rest) are extension methods, so map.GetString("key") works on any map without a cast. Job Data has every accessor, the PutAsString round-trip formats, the merge rules, string-mode storage, and what does not belong in job data.

Putting data into the map before adding the job to the scheduler:

// define the job and tie it to our DumbJob class
IJobDetail job = JobBuilder.Create<DumbJob>()
    .WithIdentity("myJob", "group1") // name "myJob", group "group1"
    .UsingJobData("jobSays", "Hello World!")
    .UsingJobData("myFloatValue", 3.141f)
    .Build();

Reading it during execution:

public class DumbJob : IJob
{
    public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default)
    {
        JobKey key = context.JobDetail.Key;

        JobDataMap dataMap = context.JobDetail.JobDataMap;

        string? jobSays = dataMap.GetString("jobSays");
        float myFloatValue = dataMap.GetFloat("myFloatValue");

        await Console.Error.WriteLineAsync("Instance " + key + " of DumbJob says: " + jobSays + ", and val is: " + myFloatValue);
    }
}
  • With a persistent JobStore the map is serialized, so its contents are prone to class-versioning problems. Standard .NET types are safe; changing a class that has serialized instances can break compatibility.
  • AdoJobStore and JobDataMap can be put in a mode that stores only primitives and strings, which rules out later serialization problems.
  • If the job class has settable properties named like keys in the map, the default JobFactory sets them when it creates the job. A custom JobFactory does not do this by default.

Naming the property instead of the key

To set a value meant for one of those properties, name the property instead of spelling its key. Given the properties:

public class DumbJob : IJob
{
    public string JobSays { get; set; } = "";
    public float FloatValue { get; set; }

    public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default) => default;
}

the key is the property's name, and the value must be of the property's type:

IJobDetail job = JobBuilder.Create<DumbJob>()
    .WithIdentity("myJob", "group1")
    .UsingJobData(j => j.JobSays, "Hello World!")
    .UsingJobData(j => j.FloatValue, 3.141f)
    .Build();
  • Use the generic JobBuilder.Create<DumbJob>(). It returns a JobBuilder<DumbJob>, which is what lets j be inferred; JobBuilder.Create() builds for IJob, which has no properties to name.
  • Nothing is instantiated. The expression is only read, never run.
  • The property must be readable and publicly settable. A property declared { private get; set; } cannot be named; give it an ordinary getter.

Mistakes fail instead of being ignored. A property of an unrelated job does not compile. These are rejected when the builder runs:

  • a property with no public setter;
  • a nested path such as j => j.Something.Nested;
  • a property reached by casting the job to another type;
  • a value that will not convert to the property's type, or would lose information doing so;
  • a null for a property that cannot hold one.

By contrast, a mistyped string key binds to nothing and leaves the job running with its defaults, and a wrong-typed value is silently coerced.

Only a name reaches the map, and the job factory finds the property by that name, with the first character upper-cased. The builder runs the same lookup and rejects a property it does not arrive back at:

  • a property whose name starts with a lower-case letter;
  • an explicit interface implementation, which is not public on the job class;
  • a name that resolves to a different property of another type, such as a new member hiding a base property.

An enum is stored as its name, so it survives a persistent store and reads sensibly in the map. Otherwise the store must be able to serialize the value, as with any job data.

Triggers work the same way through TriggerBuilder.Create<DumbJob>(), so one job can take different inputs per trigger:

ITrigger trigger = TriggerBuilder.Create<DumbJob>()
    .WithIdentity("myTrigger", "group1")
    .ForJob(job)
    .UsingJobData(j => j.JobSays, "Good evening!")
    .Build();

ForJob(IJobDetail) also checks that the job is a DumbJob; it is the one overload that knows the job's type.

The configurators in the Microsoft DI integration take the job type from the call:

q.AddJob<DumbJob>(j => j.UsingJobData(x => x.JobSays, "Hello World!"));

q.ScheduleJob<DumbJob>(
    t => t.StartNow().UsingJobData(x => x.JobSays, "Good evening!"),
    j => j.WithIdentity("myJob"));

// a trigger added on its own names the job type it fires
q.AddTrigger<DumbJob>(t => t.ForJob(jobKey).UsingJobData(x => x.JobSays, "Good evening!"));

AddTrigger without a type argument is untyped and has no properties to name. Name the job's type when you want typed trigger data.

A trigger's JobDataMap lets a job stored for use by several triggers take different inputs from each. During execution, context.MergedJobDataMap merges the JobDetail's map with the trigger's; the trigger's values override same-named values from the JobDetail.

Reading the merged map:

public class DumbJob : IJob
{
    public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default)
    {
        JobKey key = context.JobDetail.Key;

        JobDataMap dataMap = context.MergedJobDataMap;  // Note the difference from the previous example

        string? jobSays = dataMap.GetString("jobSays");
        float myFloatValue = dataMap.GetFloat("myFloatValue");
        IList<DateTimeOffset> state = (IList<DateTimeOffset>) dataMap["myStateData"]!;
        state.Add(DateTimeOffset.UtcNow);

        await Console.Error.WriteLineAsync("Instance " + key + " of DumbJob says: " + jobSays + ", and val is: " + myFloatValue);
    }
}

Or let the JobFactory set the values as properties:

public class DumbJob : IJob
{
    public string JobSays { private get; set; } = "";
    public float FloatValue { private get; set; }

    public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default)
    {
        JobKey key = context.JobDetail.Key;

        JobDataMap dataMap = context.MergedJobDataMap;  // Note the difference from the previous example

        IList<DateTimeOffset> state = (IList<DateTimeOffset>) dataMap["myStateData"]!;
        state.Add(DateTimeOffset.UtcNow);

        await Console.Error.WriteLineAsync("Instance " + key + " of DumbJob says: " + JobSays + ", and val is: " + FloatValue);
    }
}

Job "Instances"

One job class can have many job definitions (JobDetails) in the scheduler, each with its own properties and JobDataMap. For example, a SalesReportJob class can read the sales person's name from its JobDataMap and be stored as two definitions, "SalesReportForJoe" and "SalesReportForMike", with "Joe" and "Mike" in their maps.

When a trigger fires, its JobDetail is loaded and the job class is instantiated through the scheduler's JobFactory. Under AddQuartz that is MicrosoftDependencyInjectionJobFactory, which:

  1. opens a service scope;
  2. resolves the job type from the container, with constructor injection;
  3. sets the job's properties whose names match keys in the merged JobDataMap;
  4. disposes the scope when the fire ends, so a scoped DbContext belongs to one execution.

Terms used in this documentation:

TermMeans
job, job definition, JobDetail instancea stored JobDetail
job instance, instance of a job definitionone executing job
job typethe class implementing IJob

JobFactory

The scheduler's JobFactory instantiates the job when a trigger fires.

FactoryUsed
MicrosoftDependencyInjectionJobFactoryby default, under AddQuartz and under QuartzSchedulerBuilder (which builds its own container)
SimpleJobFactoryby a scheduler built without any container; the base of the default factory

SimpleJobFactory activates the type through its parameterless constructor and sets properties from the job data.

Write your own IJobFactory when a job must be built another way: resolved from a tenant's container, proxied, or handed something that only exists at fire time.

  • Set it where the scheduler is configured: q.UseJobFactory<MyJobFactory>() or q.UseJobFactory(new MyJobFactory()).
  • That scheduler's job types are not registered with the container, so ValidateOnBuild does not check them. A factory derived from MicrosoftDependencyInjectionJobFactory keeps both.
  • To keep the container factory and only add to the scope it opens, use q.ConfigureJobScope((scope, bundle, scheduler) => …) instead.
  • A factory returns a JobScope: the job, plus an optional opaque State that Quartz passes back to ReturnJob when the job finishes. Put anything the factory allocated to build the job there: a dependency injection scope, a connection, a tenant context.

Tips

There's built-in support for integrating with Microsoft Dependency Injection, which in turn lets you use other IoC containers.

Job State and Concurrency

Two attributes on the job class control state and concurrency. Both apply to a job definition (JobDetail), not to instances of the class. They sit on the class because they change how it is written.

AttributeEffect
[DisallowConcurrentExecution]no two executions of the same JobDetail run at once
[PersistJobDataAfterExecution]after Execute completes, even with a JobExecutionException, the stored JobDataMap is updated, so the next execution sees the new values

With [DisallowConcurrentExecution] on "SalesReportJob", only one "SalesReportForJoe" runs at a time, but it can run concurrently with "SalesReportForMike".

With a clustered persistent store this holds across every node. Nodes with in-memory stores share nothing, so each can run the job at the same time. To decide per trigger instead, use an overlap policy.

If you use [PersistJobDataAfterExecution], also use [DisallowConcurrentExecution]. Otherwise two concurrent executions race over which data is left stored.

Other Attributes Of Jobs

PropertyEffect
Durabilitya non-durable job is deleted once no active trigger is associated with it
RequestsRecoverya job executing during a hard shutdown (process crash, machine off) is re-executed when the scheduler starts again, with JobExecutionContext.Recovering true

The job store remembers an interrupted execution until the scheduler starts and recovers it. Rescheduling its trigger in the meantime, as re-applying your AddJob/AddTrigger registrations on startup does, keeps it. Unscheduling the trigger forgets it.

A JobDetail of your own

JobBuilder builds Quartz's own IJobDetail, which almost every application wants. To carry something of yours with the definition (a tenant, a correlation id), implement IJobDetail. Everything Quartz asks of a detail is on the interface:

public sealed class TenantJobDetail : IJobDetail
{
    public TenantJobDetail(JobKey key, JobType jobType, string tenant, JobDataMap? jobDataMap = null)
    {
        Key = key;
        JobType = jobType;
        Tenant = tenant;
        JobDataMap = jobDataMap ?? new JobDataMap();
    }

    public string Tenant { get; }

    public JobKey Key { get; }
    public string Description => $"jobs for {Tenant}";
    public JobType JobType { get; }
    public JobDataMap JobDataMap { get; }
    public bool Durable => true;
    public bool PersistJobDataAfterExecution => true;
    public bool ConcurrentExecutionDisallowed => true;
    public bool RequestsRecovery => false;

    // How a job store re-stores the data a [PersistJobDataAfterExecution] job left behind: it asks the
    // detail for a copy of itself rather than building one, which it could only do as Quartz's own type.
    public IJobDetail WithJobData(JobDataMap jobDataMap)
        => new TenantJobDetail(Key, JobType, Tenant, jobDataMap);

    public IJobDetail Clone()
        => new TenantJobDetail(Key, JobType, Tenant, new JobDataMap(JobDataMap));
}

How far it travels

A detail of your own round-trips through RAMJobStore, which holds the instances it is given and returns clones of them. It does not survive a store or transport that keeps a detail as data. The ADO.NET job store writes the columns of QRTZ_JOB_DETAILS and rebuilds every detail through JobBuilder, and the HTTP client rebuilds one from its wire payload; both return Quartz's own implementation. Anything your type carries beyond the members above is lost. Put it in the JobDataMap if it has to come back.

detail.GetJobBuilder() is an extension method over the interface, so it works on your own detail too, but what it builds is Quartz's IJobDetail. Use WithJobData to vary the data of your own detail, and Clone() to copy it.

Trimming

The Quartz package is marked IsTrimmable and declares IsAotCompatible. An application can publish with PublishTrimmed or PublishAot and get an executable that starts without a runtime installed.

StoreWhat you do
in-memorynothing
persistentregister the database with the driver's DbProviderFactory rather than its name, and declare your own job-data value types to the serializer

The repository publishes a scheduler both ways and runs it over a real SQLite store on Windows, Linux and macOS on every pull request.

A job type that arrives as a string — the JOB_CLASS_NAME column an ADO.NET store reads back, a schedule file, an HTTP request body — resolves from the types registered in code, because a trimmer cannot follow a string. Registering job types with AddJob<TJob>() or AddJobType<TJob>() is what a trimmed application does for every job it runs; a name nothing registered is refused naming the registration. The flat quartz.* keys that name a component are refused under trimming, and nothing is suppressed in the shipped assembly. See Publishing Trimmed and Native AOT for what each package claims, how to read what a publish reports, and the full recipe.

JobExecutionException

The only exception IJob.Execute(..) should throw is JobExecutionException, so wrap the body of Execute in a try-catch. The exception's directives to the scheduler are init-only properties:

catch (Exception ex)
{
    // ask the scheduler to run this fire again with the same context
    throw new JobExecutionException(ex) { RefireImmediately = true };
}
PropertyEffect
RefireImmediatelyruns this fire again with the same context
UnscheduleFiringTriggerthe trigger that fired the job does not fire again
UnscheduleAllTriggersno trigger of the job fires again

When RefireImmediately is set, the unschedule flags are ignored.

Help us by improving this page!
Last Updated: 10/19/26, 11:45 PM
Contributors: Marko Lahma, Claude Opus 5.5
Prev
Jobs And Triggers
Next
Job Data