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

The full reference for the JobDataMap introduced in More About Jobs: merge precedence, the typed accessors, PutAsString formats, persistent storage, and what to keep out.

Two maps and a merge

MapStored withUse
IJobDetail.JobDataMapthe jobthe same for every trigger that fires it
ITrigger.JobDataMapthe triggerseveral triggers drive one job with different inputs

IJobExecutionContext.MergedJobDataMap is the job's map with the trigger's map laid over it: for the same key, the trigger wins. It is built lazily, once per firing. Jobs should read it:

public sealed class ReportJob : IJob
{
    public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default)
    {
        JobDataMap data = context.MergedJobDataMap;
        string region = data.GetString("region")!;
        int lookbackDays = data.GetInt("lookbackDays");
        // ...

        return default;
    }
}

The merged map is a per-firing copy. Values written into it are not saved to the job's own map. To persist state across fires, use [PersistJobDataAfterExecution] on the job's own map.

Changed in 4.x

The scheduler context is no longer merged into the per-fire map. In 3.x context.MergedJobDataMap also carried everything in SchedulerContext, so a scheduler-wide key could silently shadow a job's own key, or be shadowed by it. The merge is now job over trigger and nothing else; read scheduler-wide values from context.Scheduler.Context.

Putting values in

JobBuilder<TJob> and TriggerBuilder<TJob> have the same three UsingJobData shapes:

IJobDetail job = JobBuilder.Create<ReportJob>()
    .WithIdentity("nightly", "reports")
    .UsingJobData("region", "emea")                     // key and value
    .UsingJobData(j => j.LookbackDays, 30)              // name the property, not the key
    .UsingJobData(existingMap)                          // merge a whole map in
    .Build();

UsingJobData(j => j.LookbackDays, 30) uses the property's name as the key and its type for the value, so a rename or a type change is a compile error instead of a silent no-op at fire time. It pairs with property injection.

Data for a single firing does not need a trigger:

await scheduler.TriggerJob(jobKey, new JobDataMap { ["reason"] = "manual re-run" }, cancellationToken);

The read side: typed accessors

JobDataMap implements IDictionary<string, object?>: indexer, TryGetValue, ContainsKey, Remove, Count, Keys, Values, Clear, plus ContainsValue and IsEmpty. It also has 17 typed accessors, as extension members on DataMapExtensions, in two families that read values the same way.

The seven named types

FamilyMembersMissing or unreadable value
throwing readersGetInt, GetLong, GetFloat, GetDouble, GetBoolean, GetString, GetDateTimeOffsetthrows
try readersTryGetInt, TryGetLong, TryGetFloat, TryGetDouble, TryGetBoolean, TryGetString, TryGetDateTimeOffsetreturns false

Each accepts the value as its own type or as an invariant-culture string:

  1. the stored type is matched first;
  2. a string is parsed with CultureInfo.InvariantCulture;
  3. any other stored type falls back to Convert semantics.

So the same job code works whether the store kept 30 as an int or as "30".

GetString returns string? instead of throwing on a missing key; the others throw. Use the TryGet… form when the key is optional. Neither form is faster.

The three generic readers, for every other type

For a type the seven do not name (a Guid, a TimeSpan, a decimal, a DateOnly, an enum, or a class of your own), use a generic reader:

AccessorEntry missingEntry unreadable as TEntry readable as T
TryGet<T>(key, out T value)falsefalsetrue, value out
Get<T>(key)KeyNotFoundExceptionInvalidCastException naming both typesthe value
GetValueOrDefault<T>(key, defaultValue)defaultValuedefaultValuethe value

"Readable" means: the stored type first, then the invariant string form for every type PutAsString writes, and an enum by name, case-insensitively. Get<Guid>("batchId") reads a Guid stored as a string, Get<TimeSpan>("window") reads "06:00:00", and Get<DayOfWeek>("day") reads "Monday". A type with no Quartz string form, such as your own class, is a plain type test.

// False when the entry is missing and when it holds something else.
if (data.TryGet<ReportOptions>("options", out ReportOptions? options))
{
    // ...
}

// Throws KeyNotFoundException for a missing entry, InvalidCastException for a wrong one -
// the two mistakes told apart, where TryGet answers false to both.
ReportOptions required = data.Get<ReportOptions>("options");

// Neither throws nor distinguishes: missing and wrong-typed both give the fallback.
ReportOptions effective = data.GetValueOrDefault("options", new ReportOptions());
  • Use Get<T> when the entry is required: it tells a missing entry from a wrong one.
  • Do not use GetValueOrDefault<T> where a mistyped key must be noticed: it returns the fallback for both.

Tips

SchedulerContext has the same readers. The PutAsString writers belong to JobDataMap only, because they take part in its change tracking.

Changed in 4.x

The Get*Value / Get*ValueFromString accessor pairs are gone, and so are the nullable getters (GetNullableInt and friends). One Get…/TryGet… pair per type replaces both. The accessors also moved off StringKeyDirtyFlagMap, which is internal now along with DirtyFlagMap. Call sites are unchanged (map.GetString(…) still compiles), but nothing should name the old types, and the Quartz.Util namespace is gone.

The less common named accessors became Get<T>, which now does the coercion:

3.x4.x
GetGuidGet<Guid>
GetTimeSpanGet<TimeSpan>
GetDecimalGet<decimal>
GetCharGet<char>
GetDateTimeGet<DateTime>
GetDateOnlyGet<DateOnly>
GetTimeOnlyGet<TimeOnly>
GetEnum<T>Get<T>

Each TryGet… changed likewise. Reading is unchanged, including the string forms PutAsString writes.

Storing values as strings

PutAsString writes a value in a form any store can keep:

JobDataMap data = new();
data.PutAsString("runAt", DateTimeOffset.UtcNow);   // "O": 2026-08-22T09:15:00.0000000+00:00
data.PutAsString("window", TimeSpan.FromHours(6));  // invariant "06:00:00"
data.PutAsString("batchId", Guid.NewGuid());
data.PutAsString("lookbackDays", 30);               // any IFormattable
OverloadWritten as
PutAsString(string, DateTime)round-trip "O", invariant
PutAsString(string, DateTimeOffset)round-trip "O", invariant
PutAsString(string, DateOnly)round-trip "O": yyyy-MM-dd
PutAsString(string, TimeOnly)round-trip "O"
PutAsString(string, TimeSpan)invariant default format
PutAsString(string, Guid)invariant default format
PutAsString(string, bool)invariant default format
PutAsString(string, char)invariant default format
PutAsString<T>(string, T) where T : IFormattableinvariant, default format

Each round-trips through the matching accessor. PutAsString("runAt", offset) then GetDateTimeOffset("runAt") returns the same instant and offset. Get<DateTime> parses with round-trip semantics, so a DateTime written as "O" keeps its original Kind instead of becoming an unspecified local time. Get<Guid>, Get<TimeSpan>, Get<DateOnly> and Get<TimeOnly> read what PutAsString wrote.

Why string-safe storage matters

A persistent store reads job data back through the serializer or, in string mode, as strings.

The serializer. By default a persistent store serializes the whole map.

  • Every value must be serializable by the configured serializer.
  • A value whose type changes shape must stay readable by the new version. A renamed property on a stored options class makes the job throw on its next fire, possibly months after the deploy. Standard framework types are safe; your own types are a versioning commitment.
  • A persistent store accepts the types the accessors cover (string, bool, char, the numeric types, DateTime, DateTimeOffset, TimeSpan, Guid, DateOnly, TimeOnly and enums) plus Dictionary<string, string>. It refuses anything else when the job is stored, instead of writing a blob that fails to load later.
  • Both serializers refuse the same set and write it the same way, so you can switch between them.
  • A string map's entries cannot use the name $type. Json.NET writes a value's type there, and both readers take it as metadata, so a map with an entry under it is refused.

To store a type of your own, do one of:

  • declare it with SystemTextJsonSerializerRegistry.AddTypeInfoResolver on the default serializer (the same registration a trimmed or native-AOT publish needs);
  • declare it with NewtonsoftJsonSerializerRegistry.AddJobDataValueType<T>() on the Newtonsoft serializer;
  • serialize it yourself and store the string.

A declared type is read back only by the serializer that wrote it, so the string is the portable choice.

String mode. AdoJobStoreOptions.StoreJobDataAsStrings (flat key quartz.jobStore.useProperties) stores the map as name/value string pairs instead of a serialized blob. Its other store options are in the Configuration Reference.

q.UsePersistentStore(s =>
{
    s.UseSqlServer(connectionString);
    s.ConfigureStore(o => o.StoreJobDataAsStrings = true);
});

It removes the versioning problem and makes QRTZ_JOB_DETAILS.JOB_DATA readable in a query tool. The rule: every value must be a string. Storing a job with a DateTimeOffset in its map fails under string mode. Write values with PutAsString; the accessors read them the same way in either mode.

Tips

Turn StoreJobDataAsStrings on at the start of a project, not in the middle. Switching it on with data already in the tables leaves rows the store cannot read.

Property injection: the other read side

If a job has settable properties named like keys in the merged map, the default job factory sets them before Execute runs:

public sealed class ReportJob : IJob
{
    public string Region { get; set; } = "";
    public int LookbackDays { get; set; }

    public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default)
    {
        // Region and LookbackDays are already set

        return default;
    }
}
  • Conversion follows the accessors' rules: "30" in the map sets an int LookbackDays.
  • A key with no matching property, or a value that cannot be converted, is handled by PropertySettingJobFactory.PropertyMismatchBehavior: Ignore, Warn or Throw. Use Warn in development; otherwise a property silently stays at its default.
  • UsingJobData(j => j.LookbackDays, 30) is the matching write side: name the property, get the key.

A typed input: the third read side

A job whose data is one payload (a message, a command, an event) can declare its type with IJob<TInput>. The payload arrives as a parameter:

public sealed record SendEmail(string To, string Subject);

public sealed class SendEmailJob : IJob<SendEmail>
{
    public ValueTask Execute(IJobExecutionContext context, SendEmail input, CancellationToken cancellationToken = default)
    {
        // input.To, input.Subject - no keys, no accessors, no casts
        return default;
    }
}

public static class TypedInputScheduling
{
    public static async ValueTask Schedule(IScheduler scheduler, CancellationToken cancellationToken)
    {
        await scheduler.ScheduleJob(
            JobBuilder.Create<SendEmailJob>()
                .WithIdentity("welcome", "email")
                .Build(),
            TriggerBuilder.Create<SendEmailJob>()
                .WithIdentity("welcome-3401", "email")
                .StartNow()
                .UsingInput(new SendEmail("[email protected]", "Welcome"))
                .Build(),
            cancellationToken: cancellationToken);
    }
}
  • UsingInput is on JobBuilder<TJob>, TriggerBuilder<TJob>, and the AddJob and AddTrigger configurators. It is only offered for a job that declares an input; using it on any other job is a compile error.
  • The value is stored in the ordinary JobDataMap under the reserved key SchedulerConstants.JobInput (QRTZ_JOB_INPUT). The scheduler serializes it to a string when the job or trigger is stored, so it survives StoreJobDataAsStrings, the serializer's type check, the blob column and the HTTP API.
  • An input on the trigger overrides an input on the job.
  • A job that is not an IJob<TInput> can read the payload with context.GetInput<SendEmail>(), which returns null when there is none.
  • An IJob<TInput> whose input is missing fails the firing with a SchedulerException naming the key, instead of running on a default payload.
  • The input type is inferred from the argument. A payload held as a base type is stored and read as that base type. Pass the type argument when the static type is not the one you mean: UsingInput<SendEmailJob, SendEmail>(payload).
  • Put a per-firing input on the trigger. A [PersistJobDataAfterExecution] job re-stores its own map after every firing, so an input on the job is written back each time. That is harmless, since it is already a string.

The scheduler's IJobInputSerializer writes the payload. It defaults to SystemTextJsonJobInputSerializer, built from the same registry as the store's serializer; see JSON Serialization. A trimmed or native-AOT application declares its payload types with SystemTextJsonSerializerRegistry.AddTypeInfoResolver, as for a job data value type.

Persisting changes across fires

By default a job's stored map is written once and read many times. With [PersistJobDataAfterExecution] the job's own JobDataMap is saved after every execution, so a counter or a watermark survives:

[PersistJobDataAfterExecution]
[DisallowConcurrentExecution]
public sealed class IncrementalSyncJob : IJob
{
    public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default)
    {
        JobDataMap data = context.JobDetail.JobDataMap;
        data.PutAsString("lastSyncedAt", DateTimeOffset.UtcNow);
        return default;
    }
}

Always add [DisallowConcurrentExecution] too. Without it, two firings read the same map, both write, and one write is lost.

The map tracks changes and is only written when it changed. To force a write the map did not detect, such as an in-place change to a stored object, set the well-known key:

data[SchedulerConstants.ForceJobDataMapDirty] = "true";

Thread safety

JobDataMap is not thread-safe. A job without [DisallowConcurrentExecution] can have several firings running at once, and they share the stored IJobDetail's map. Reading it concurrently is fine; do not change it from a job that can run concurrently with itself.

Each firing gets its own MergedJobDataMap, so per-execution data is isolated.

What does not belong in job data

Job data is durable. On a persistent store it is in QRTZ_JOB_DETAILS.JOB_DATA and QRTZ_TRIGGERS.JOB_DATA, in every backup, in the fired-trigger history, and in the dashboard and HTTP API for anyone who can read a job's detail. Keep out:

  • Credentials, tokens, connection strings. Register the secret with the container instead. The shipped SendMailJob does this: its options type has no user name or password field. See Keep the SMTP credential out of job data; the pattern applies to any job that needs a secret.
  • Large payloads. Job data is read on every fire and, under [PersistJobDataAfterExecution], written on every fire. Store an identifier and fetch the payload in the job.
  • Live objects. A DbConnection, an HttpClient or a logger comes from the container through the job's constructor.

Job data is for the inputs that distinguish one scheduled instance from another.

Help us by improving this page!
Last Updated: 10/6/26, 2:13 PM
Contributors: Marko Lahma, Claude Opus 5.5
Prev
More About Jobs & JobDetails
Next
More About Triggers