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

Coming from TickerQ

This page maps TickerQ 10.4.0 (source at commit c6ed1e7d, which that release's packages name; there is no v10.4.0 tag) to Quartz.NET 4.1. To weigh the two, see Comparison.

The main difference: a TickerQ job is an attributed method, found by a source generator and scheduled by its string name; a Quartz job is a type, registered and scheduled by that type, so there is no name to keep in step with a method.

// [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 * * ?"));
});
  • Build-time cron checks carry over. TickerQ reports an unparseable expression as TQ003; Quartz 4.2 does the same with its run-time parser. See Compile-Time Checks.
  • So do attributes. [QuartzJob] and [CronTrigger] declare a job and schedule on the class; a source generator writes the registration.
  • A job can be a lambda, from 4.3. q.ScheduleJob("cleanup", (IRepo repo, CancellationToken ct) => …, t => t.WithCronSchedule(…)) takes its services as parameters, as a [TickerFunction] method does. See Delegate Jobs.
  • A schedule can skip the dialect, from 4.3. t.WithCronSchedule(cron => cron.Every(TimeSpan.FromMinutes(5))) is */5 * * * * without the six-field rewrite, and cron.AtTime(new TimeOnly(3, 0)).OnWeekdays() is 0 3 * * 1-5. See Cron Triggers.
  • An expression built at run time is checked at run time. Build one with CronExpressionBuilder, or check one by asking the trigger when it fires.

The API, side by side

TickerQQuartz.NETDifference
[TickerFunction("name")] on a methoda class implementing IJob, registered with q.AddJob<T>(…) or with [QuartzJob] on the class; from 4.3, a lambda with q.AddJob("name", …)the schedule names the class, or the lambda's key
[TickerFunction("name", "*/5 * * * *")]q.AddTrigger<T>(t => t.WithCronSchedule(…)), [CronTrigger("0 0/5 * * * ?")] on the class, or from 4.3 q.ScheduleJob("name", lambda, t => t.WithCronSchedule(…))the schedule is its own trigger, so one job can have several
[TickerFunction("name", "%Jobs:Cleanup%")], an expression from IConfigurationfrom 4.3, [CronTrigger("0 0 0/6 * * ?", ConfigurationKey = "Jobs:Cleanup")] — see A schedule from configurationthe literal is the fallback and is checked at build time; a configured value that does not parse stops the host
new TimeTickerEntity { Function = "name", ExecutionTime = … }scheduler.ScheduleJob<TJob, TInput>(input, at)
timeTicker.AddAsync<WelcomeJob>(executionTime)the same callboth typed; Quartz's also carries the payload type
new CronTickerEntity { Expression = … }a cron trigger through TriggerBuilder
RunCondition on a child ticker.StartAfter(parentTriggerKey, condition) on the follow-up's triggersee Chaining
Retries + RetryIntervals = [30, 120, 600]RetryPolicy.Explicit(…) on the triggerthe wait is held in the store, not the process
maxConcurrency on [TickerFunction]an execution group and a limitthe limit can be cluster-wide
TickerTaskPriorityPriority on the triggerTickerQ's orders dispatch per function; Quartz's breaks ties between triggers due together
TickerFunctionContextIJobExecutionContext
TerminateExecutionExceptionreturn normally, or throw JobExecutionException with an unschedule instruction

A one-shot ticker is the overload taking a payload and a time. SendWelcomeEmailJob is an IJob<string>, so the payload type is checked at the call site:

// 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 to pin a trigger to a node
s.MinPollingIntervalIdleWaitTime — a ceiling, not a floor: 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(), plus 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 (NCrontab)Quartz (reference)
Fieldssix, seconds first; or five, expanded with a 0 seconds fieldsix or seven
Special characters*, ,, -, /, digits and namesthose, plus L, W, # and H
Day fields* in bothone of the two must be ? (the two are a union)
  • */5 * * * * * becomes */5 * * * * ?; five-field 0 */6 * * * becomes 0 0 0/6 * * ?.
  • A five-field expression in the default format is an error naming the rewrite, not a silently different schedule. CronFormat.Unix reads it as written.

There is no polling floor

TickerQ's MinPollingInterval (one second by default) is the minimum pause between database polls, so it bounds finer schedules. Quartz's IdleWaitTime (thirty seconds by default) is a ceiling: the thread waits until the earliest trigger due within it, and a schedule change wakes it early. A seconds-level cron fires at its stated cadence, including one TickerQ was rounding up. See What the loop costs while it waits.

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

Both default to the machine's zone (TickerQ's SchedulerTimeZone, Quartz's TimeZoneInfo.Local per cron trigger), but Quartz has no scheduler-wide setting. Set InTimeZone(…) explicitly on every trigger.

A missed occurrence is a decision, not a late run

TickerQ's fallback sweep runs an overdue row late, unless SkipStaleCronOccurrencesOnStartup() drops occurrences older than its threshold. Quartz decides per trigger: FireAndProceed, IgnoreMisfires or DoNothing. The default SmartPolicy is FireAndProceed for cron (one catch-up, then on schedule). DoNothing is the nearest to SkipStaleCronOccurrencesOnStartup, set per trigger instead of by a start-up sweep.

Retries wait in the store, not in the worker

TickerQ retries inside the execution with a Task.Delay between attempts, holding the worker slot and the row's lease, and ending with the process. A Quartz retry is a new fire time in 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 retry survives a node dying, and any node can run it.
  • Intervals are TimeSpans, not integers.
  • A retry that would land at or after the next occurrence is dropped: a ten-minute wait on a five-minute schedule does nothing. See Retrying Failed Jobs.
  • When attempts run out, TickerQ marks the ticker failed; Quartz returns the trigger to its schedule and reports it through ITriggerListener.TriggerRetriesExhausted, a log event, a counter and a final history row. From 4.3, PauseTriggerWhenRetriesExhausted() stops it instead, paused with the error as the reason, which is closer to TickerQ's failed ticker.

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

LimitTickerQQuartz
This processMaxConcurrency in ConfigureSchedulerthe thread pool size
One function or categorymaxConcurrency on [TickerFunction], a SemaphoreSlim in the processan execution group limit, optionally 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 narrower: one firing per job key at a time, cluster-wide with a persistent store. From 4.3 an overlap policy decides it per trigger: skip, buffer one, or cancel the running firing.

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

TickerQ's dashboard defaults to AuthMode.None. Quartz's fails before the web host binds a listener if the mapping has neither RequireAuthorization nor AllowAnonymous and the host has no fallback policy. Add one call: app.MapQuartzDashboard().RequireAuthorization(…) after AddQuartzDashboard(). See Production hardening for read-only mode and the job type allow-list.

Chaining and its condition

TickerQ's RunCondition becomes a continuation: StartAfter(parentTriggerKey, condition) on the follow-up's trigger, held by the store in TriggerState.Awaiting and released inside the parent's transaction. The parent is a trigger's firing, not a row.

TickerQ RunConditionQuartz ContinuationCondition
OnSuccessOnSuccess
OnFailureOnFailure
OnCancelledOnCancellation
OnFailureOrCancelledOnFailure and OnCancellation combined; the enum is flags
OnAnyCompletedStatusOnAnyOutcome
InProgressnone: a continuation is released by an outcome, and a running firing has none
—OnVeto, for a firing an ITriggerListener refused

TickerQ's chaining is TimeTicker-only; JobChainingJobListener works on job keys, so a cron trigger's job can have a follow-up.

The store is ADO.NET, not EF Core

TickerQ's AddOperationalStore(ef => …) adds tables to your DbContext. Quartz uses its own tables (QRTZ_-prefixed, prefix configurable) with no DbContext or EF migration; create them with ProvisionSchema() or the script for your database. A schedule change is not part of SaveChanges; for one transaction with your data, use an ambient transaction.

Running both while you move

They are separate hosted services on separate schemas, so they can share a host. A recurring job registered in both fires twice, unreported: remove it from 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: 10/6/26, 2:13 PM
Contributors: Marko Lahma, Claude Opus 5.5
Prev
Coming from Hangfire
Next
Embedding Quartz in a Library