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

Coming from TickerQ

TickerQ's model is small, which makes the mapping to Quartz.NET short. It is written against TickerQ 10.4.0 — source at commit c6ed1e7d, which is the commit that release's packages name, since there is no v10.4.0 tag — and Quartz.NET 4.1. Comparison is where the two are weighed against each other, including what TickerQ does better. This page is for when the decision is already made.

One difference decides most of the others: a TickerQ job is a method with an attribute, found by a source generator and scheduled by its string name; a Quartz job is a type, registered by that type and scheduled through it.

// [TickerFunction("cleanup", "0 */6 * * *")] on a method
services.AddQuartz(q =>
{
    q.AddJob<CleanupJob>(j => j.WithIdentity("cleanup"));
    q.AddTrigger<CleanupJob>(t => t
        .ForJob("cleanup")
        // NCrontab takes '*' in both day fields; Quartz wants '?' in one of them, and it
        // reads six fields with seconds first.
        .WithCronSchedule("0 0 0/6 * * ?"));
});

The compile-time check comes with you. TickerQ's generator makes a cron expression that will not parse a build error (TQ003); Quartz 4.2 does the same, with the parser that reads the expression at run time — see Compile-Time Checks. The attribute shape comes too: [QuartzJob] and [CronTrigger] declare a job and its schedule on the class, and a source generator writes the registration above for you. An expression assembled at run time is still read at run time, and for that CronExpressionBuilder builds an expression without writing the string, and asking the trigger when it fires checks one you have.

What you gain is that the job type is the registration: no string to keep in step with a method name, and no second place the name can be wrong.

The API, side by side

TickerQQuartz.NETWhere it differs
[TickerFunction("name")] on a methoda class implementing IJob, registered with q.AddJob<T>(…) or with [QuartzJob] on the classthe class is what the schedule names
[TickerFunction("name", "*/5 * * * *")]q.AddTrigger<T>(t => t.WithCronSchedule(…)), or [CronTrigger("0 0/5 * * * ?")] on the classthe schedule is a trigger of its own, so one job can have several
new TimeTickerEntity { Function = "name", ExecutionTime = … }scheduler.ScheduleJob<TJob, TInput>(input, at)
timeTicker.AddAsync<WelcomeJob>(executionTime)the same callboth are typed; Quartz's carries the payload type too
new CronTickerEntity { Expression = … }a cron trigger through TriggerBuilder
RunCondition on a child ticker.StartAfter(parentTriggerKey, condition) on the follow-up's triggerthe parent is a trigger's firing rather than a row, and there is no InProgress. See Chaining
Retries + RetryIntervals = [30, 120, 600]RetryPolicy.Explicit(…) on the triggerthe wait is held in the store rather than in the process
maxConcurrency on [TickerFunction]an execution group and a limitthe limit can be counted across the cluster, not only per process
TickerTaskPriorityPriority on the triggerTickerQ's orders dispatch per function; Quartz's breaks ties between triggers due at once
TickerFunctionContextIJobExecutionContext
TerminateExecutionExceptionreturn normally, or throw JobExecutionException with an unschedule instruction

A one-shot ticker is the overload that takes a payload and a time. SendWelcomeEmailJob here is an IJob<string>, so the payload type is checked at the call site rather than being a column:

// await timeTicker.AddAsync(new TimeTickerEntity { Function = "send-welcome", ... })
await scheduler.ScheduleJob<SendWelcomeEmailJob, string>(
    "[email protected]",
    TimeSpan.FromSeconds(30),
    cancellationToken: cancellationToken);

Configuration

TickerQQuartz.NET
builder.Services.AddTickerQ(opt => …)builder.AddQuartz(q => …)
app.UseTickerQ()builder.AddQuartzHostedService()
app.UseTickerQ(TickerQStartMode.Manual)builder.AddQuartzHostedService(o => o.AutoStart = false), then scheduler.Start()
s.MaxConcurrency = 16q.UseDefaultThreadPool(maxConcurrency: 16)
s.SchedulerTimeZone = …InTimeZone(…) on each trigger — there is no scheduler-wide zone
s.NodeIdentifier = …QuartzSchedulerOptions.InstanceId, and PreferredNode if a trigger must run somewhere particular
s.MinPollingIntervalIdleWaitTime — but it is a ceiling on an idle wait, not a floor on a cadence: see below
s.FallbackIntervalCheckerthe misfire handler, whose cadence follows MisfireThreshold
SkipStaleCronOccurrencesOnStartup()the DoNothing misfire instruction, per trigger
opt.AddOperationalStore(ef => …)q.UsePersistentStore(s => s.UsePostgres(cs)) — ADO.NET, not EF Core
opt.AddDashboard()services.AddQuartzDashboard() + app.MapQuartzDashboard() — and an authorization decision
opt.AddOpenTelemetryInstrumentation()AddSource(QuartzInstrumentation.ActivitySourceName) and AddMeter(QuartzInstrumentation.MeterName) — no package needed
opt.WithJsonContext(AppJsonContext.Default)store.UseSystemTextJsonSerializer(r => r.AddTypeInfoResolver(…)) — Publishing Trimmed and Native AOT

The differences that bite

The cron dialect is not the same one

TickerQ parses with NCrontab: six fields with seconds first, or five that are expanded with a 0 seconds field, and a grammar of *, ,, -, /, digits and names. Quartz's is six or seven and supports L, W, # and H besides — but the part that breaks a copied expression is smaller than any of that: one of the two day fields must be ? rather than *, because Quartz reads the two as a union and ? is how one of them stands aside.

So TickerQ's */5 * * * * * is Quartz's */5 * * * * ?, and TickerQ's five-field 0 */6 * * * is 0 0 0/6 * * ?. A five-field expression handed to Quartz in the default format is an error that names the rewritten form rather than a silently different schedule; CronFormat.Unix reads one as written if that is what you want.

There is no polling floor

MinPollingInterval is a second by default and is a floor: it is the minimum pause between database polls, so a TickerQ schedule finer than that is bounded by it.

IdleWaitTime is the nearest Quartz setting and it is the opposite shape. The scheduling thread asks the store for the triggers due inside the next IdleWaitTime — thirty seconds by default — waits until the earliest of those, or until IdleWaitTime elapses if there were none, and is woken early when something changes the schedule. It bounds how long the thread sits idle, not how soon a trigger may fire, so a seconds-level cron fires at the cadence it states. An expression that was quietly rounded up starts firing as written.

Time zones are per trigger, and the default is the machine's

SchedulerTimeZone is one setting for the whole scheduler and defaults to the machine's local zone. A Quartz cron trigger also defaults to TimeZoneInfo.Local, so the default survives the move — but there is no scheduler-wide setting to carry across. Whatever SchedulerTimeZone was set to has to be repeated as InTimeZone(…) on each trigger, and the safe move is to say it explicitly everywhere rather than to rely on two defaults agreeing.

A missed occurrence is a decision, not a late run

TickerQ has no misfire policy: an overdue row is picked up by the fallback sweep and runs late, unless SkipStaleCronOccurrencesOnStartup() was called, which drops occurrences older than its threshold. Quartz asks the question per trigger, and the answer is one of three — FireAndProceed, IgnoreMisfires or DoNothing. SmartPolicy, the default, means FireAndProceed for a cron trigger: one catch-up, then back on schedule. DoNothing is the nearest thing to what SkipStaleCronOccurrencesOnStartup does, and unlike it, it is a property of the trigger rather than a start-up sweep.

Retries wait in the store, not in the worker

Retries and RetryIntervals retry inside the execution: a Task.Delay between attempts that holds the worker slot and the row's lease for the whole sequence, and that dies with the process. Quartz's retry is a new fire time written to the store:

// Retries = 3, RetryIntervals = [30, 120, 600]
services.AddQuartz(q =>
{
    q.AddJob<CleanupJob>(j => j.WithIdentity("cleanup"));
    q.AddTrigger<CleanupJob>(t => t
        .ForJob("cleanup")
        .WithCronSchedule("0 0 0/6 * * ?")
        .WithRetryPolicy(RetryPolicy.Explicit(
            TimeSpan.FromSeconds(30),
            TimeSpan.FromMinutes(2),
            TimeSpan.FromMinutes(10))));
});

The slot is released between attempts, a node that dies during a ten-minute wait does not take the retry with it, and any node can run the attempt. What you give up is that the array is now three TimeSpans rather than three integers, and that a retry is dropped when it would land at or after the trigger's next scheduled occurrence — a ten-minute wait on a five-minute schedule quietly does nothing. That rule and the rest are Retrying Failed Jobs.

When the attempts run out, TickerQ records the ticker as failed and Quartz puts the trigger back on its ordinary schedule — but it says so first, through ITriggerListener.TriggerRetriesExhausted, a log event, a counter and a history row marked as final.

Concurrency has two settings, and one of them can be the cluster's

MaxConcurrency in ConfigureScheduler is how much this process runs at once; maxConcurrency on [TickerFunction] is how much of one function runs at once, enforced by a SemaphoreSlim in that process. Quartz splits the same two ideas, and the second one can be counted across every node sharing the store:

services.AddQuartz(q =>
{
    // s.MaxConcurrency = 16 — how much this process runs at once
    q.UseDefaultThreadPool(maxConcurrency: 16);

    // maxConcurrency on [TickerFunction] — how much of one category runs at once, except
    // that the scope is yours to choose
    q.UseExecutionLimits(limits =>
    {
        limits.ForGroup("cleanup", maxConcurrent: 1, ExecutionLimitScope.Cluster);
    });
});

[DisallowConcurrentExecution] is the third, narrower tool: one firing of a given job key at a time, cluster-wide with a persistent store, with no number to choose.

The dashboard will not start until you say who may reach it

TickerQ's dashboard defaults to AuthMode.None and its documentation says so plainly: "By default the dashboard has no authentication — it's publicly accessible." Quartz's takes the opposite default and enforces it at start-up: a mapping that carries neither RequireAuthorization nor AllowAnonymous, under a host with no fallback policy, fails before the web host binds a listener.

That means a straight port of a TickerQ setup will refuse to start, and the fix is one call at the map site. AddQuartzDashboard() registers the services; app.MapQuartzDashboard().RequireAuthorization(…) maps them. Production hardening is the whole model, including read-only mode and an allow-list of the job types that may be named through it.

Chaining and its condition

RunCondition settles a child ticker on the parent's outcome — OnSuccess, OnFailure, OnCancelled, OnFailureOrCancelled, OnAnyCompletedStatus or InProgress. Quartz's counterpart is a continuation: StartAfter(parentTriggerKey, condition) on the follow-up's trigger, held by the store in TriggerState.Awaiting and settled by the parent's completion inside the parent's own transaction.

The conditions map one for one except at the ends. OnSuccess, OnFailure and OnCancellation are the same three, ContinuationCondition is flags so OnFailureOrCancelled is OnFailure | OnCancellation, OnAnyCompletedStatus is OnAnyOutcome, and Quartz adds OnVeto for a firing an ITriggerListener refused. There is nothing corresponding to InProgress: a continuation is released by an outcome, and a firing that has not ended does not have one.

One thing does get better in the move: TickerQ's chaining is TimeTicker-only, so a recurring job cannot have a follow-up. JobChainingJobListener is about job keys, so a cron trigger's job can.

The store is ADO.NET, not EF Core

AddOperationalStore(ef => …) puts TickerQ's tables in your DbContext. Quartz's persistent store talks to the database directly, with its own tables and its own DDL, so there is no DbContext to add it to and no EF Core migration to generate. The schema is created either by ProvisionSchema() or by running the script for your database; the tables are QRTZ_-prefixed and the prefix is configurable.

The practical consequence is that a Quartz schedule change is not part of your SaveChanges. If a schedule must be written in the same transaction as your own data, that is the ambient transaction shape, not the EF Core one.

Running both while you move

The two are ordinary hosted services against separate schemas and know nothing of each other, so they can share a host for as long as the move takes. The one thing worth deciding early is which of them owns a given schedule: a recurring job registered in both fires twice, and nothing will tell you. Remove it from the one before adding it to the other.

See also

  • Comparison — the two weighed against each other, sourced
  • One-Off Job — the ScheduleJob<TJob, TInput> overloads in full
  • Retrying Failed Jobs — the retry policy and its rules
  • Cron Expression Reference — the dialect, and how to check an expression
  • Publishing Trimmed and Native AOT — the equivalent of WithJsonContext
Help us by improving this page!
Last Updated: 9/19/26, 9:30 PM
Contributors: Marko Lahma, Claude Opus 5 (1M context)
Prev
Coming from Hangfire
Next
Embedding Quartz in a Library