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

Introduction

cron is a UNIX tool that has been around for a long time, so its scheduling capabilities are powerful and proven. The CronTrigger class is based on the scheduling capabilities of cron.

CronTrigger uses "cron expressions", which are able to create firing schedules such as: "At 8:00am every Monday through Friday" or "At 1:30am every last Friday of the month".

Cron expressions are powerful, but can be pretty confusing. This tutorial aims to take some of the mystery out of creating a cron expression, giving users a resource which they can visit before having to ask in a forum or mailing list.

Format

A cron expression is a string comprised of 6 or 7 fields separated by white space. Fields can contain any of the allowed values, along with various combinations of the allowed special characters for that field. The fields are as follows:

Field NameMandatoryAllowed ValuesAllowed Special Characters
SecondsYES0-59, - * / H
MinutesYES0-59, - * / H
HoursYES0-23, - * / H
Day of monthYES1-31, - * ? / L W H
MonthYES1-12 or JAN-DEC, - * / H
Day of weekYES1-7 or SUN-SAT, - * ? / L # H
YearNOempty, 1970-2099, - * /

So cron expressions can be as simple as this: * * * * ? *

or more complex, like this: 0/5 14,18,3-39,52 * ? JAN,MAR,SEP MON-FRI 2002-2010

Tips

You do not need an online generator to write one of these, and the library is the only thing that agrees with the library: build an expression with CronExpressionBuilder, and check one by asking Quartz.NET when it fires.

There are many cron standards and implementations, and a generator you find online targets Java Quartz or plain Unix cron. It will not know H or MON/2, so read what it gives you as a draft and confirm it here. A generator that emits the five-field Unix form can be read as written - see The Unix five-field form.

Special characters

  • * ("all values") - used to select all values within a field. For example, * in the minute field means "every minute".

  • ? ("no specific value") - allowed in the day-of-month and day-of-week fields, where it is a synonym for *: both say that the field names no days. Use it when you need to specify something in one of the two fields but not the other. For example, if I want my trigger to fire on a particular day of the month (say, the 10th), but don't care what day of the week that happens to be, I would put 10 in the day-of-month field, and ? in the day-of-week field. See the examples below for clarification.

  • - - used to specify ranges. For example, 10-12 in the hour field means "the hours 10, 11 and 12".

  • , - used to specify additional values. For example, MON,WED,FRI in the day-of-week field means "the days Monday, Wednesday, and Friday".

  • / - used to specify increments. For example, 0/15 in the seconds field means "the seconds 0, 15, 30, and 45". And 5/15 in the seconds field means "the seconds 5, 20, 35, and 50". You can also specify / after the * character - in this case * is equivalent to having 0 before the /. 1/3 in the day-of-month field means "fire every 3 days starting on the first day of the month". A day-of-week step may start from a name: MON/2 is 2/2, Monday, Wednesday and Friday — but see MON/2 is a step through the week if the expression came from 3.x.

  • L ("last") - has different meaning in each of the two fields in which it is allowed. For example, the value L in the day-of-month field means "the last day of the month" - day 31 for January, day 28 for February on non-leap years. If used in the day-of-week field by itself, it simply means "7" or "SAT". But if used in the day-of-week field after another value, it means "the last xxx day of the month" - for example 6L means "the last Friday of the month". You can also specify an offset from the last day of the month, such as L-3 which would mean the third-to-last day of the calendar month. The L option can be used in a list, but there can only be one occurrence of the L. For example 1,15,L would mean trigger on the 1st, 15th and Last Day of the month.

  • W ("weekday") - used to specify the weekday (Monday-Friday) nearest the given day. As an example, if you were to specify 15W as the value for the day-of-month field, the meaning is: "the nearest weekday to the 15th of the month". So if the 15th is a Saturday, the trigger will fire on Friday the 14th. If the 15th is a Sunday, the trigger will fire on Monday the 16th. If the 15th is a Tuesday, then it will fire on Tuesday the 15th. However if you specify 1W as the value for day-of-month, and the 1st is a Saturday, the trigger will fire on Monday the 3rd, as it will not 'jump' over the boundary of a month's days. The W character can only be specified when the day-of-month is a single day, not a range or list of days.

Tips

The L and W characters can also be combined in the day-of-month field to yield LW, which translates to "last weekday of the month". This field can also be used in a list, for example 1,15,LW meaning 1st, 15th and Last Weekday of the month. LW supports an offset value, which will be calculated by first identifying last weekday, then subtracting the offset. for example LW-2

  • # - used to specify "the nth" XXX day of the month. For example, the value of 6#3 in the day-of-week field means "the third Friday of the month" (day 6 = Friday and "#3" = the 3rd one in the month). Other examples: 2#1 = the first Monday of the month and 4#5 = the fifth Wednesday of the month. Note that if you specify #5 and there is not 5 of the given day-of-week in the month, then no firing will occur that month.

  • @ - names a whole schedule instead of one field. @daily is the expression; there is nothing else in it. See Macros below for the set.

Tips

The legal characters and the names of months and days of the week are not case sensitive. MON is the same as mon.

Forms the parser refuses

Six shapes parsed on 3.x and then meant something other than what they said — a special character was dropped on the floor, or a step degenerated. Each is a FormatException in 4.x, and the message names the expression that says what the author meant.

WrittenWhat it used to meanWrite instead
1-5Wthe W was dropped, leaving 1-51W,2W,3W,4W,5W, or drop the W. W applies to a single day, not a range or a list
? * L-3, ? * LWthe suffix was dropped, leaving SaturdayL-3 and LW belong to day-of-month. A bare L in day-of-week is still Saturday
MON,FRI#3the third Monday; the Friday was never firedone trigger per day, or drop the #. # applies to the whole field
5C, 1C5 / 1 — C ("calendar") was never implementedWithCalendarName, which is what a calendar is for
*/0, 5/0, 0-10/0no step at all* for every value, or a step of 1 or more
0-10/120an unchecked step; 0/120 was already rejecteda step inside the field's range

If a database may hold one of these expressions, audit it before upgrading: Before you upgrade has the query.

MON/2 is a step through the week

A textual day-of-week may be followed by a step since 4.1, and it means exactly what its numeric twin means: MON/2 is 2/2, which is Monday, Wednesday and Friday. MON-FRI/2 is 2-6/2, the same three days. The step runs to Saturday and does not wrap, as it does after a number.

An expression carried over from 3.x means something else

On 3.x, MON/2 meant every second Monday — a fortnight, not a step. On 4.0 it was rejected outright. On 4.1 it parses and fires on Monday, Wednesday and Friday: 26 fires a year become 156, and nothing is logged, because the expression is valid.

Audit for it before upgrading — Before you upgrade has the query — and rewrite what you find. Every second Monday is RecurrenceScheduleBuilder.Create("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO"). RecurrenceTrigger anchors the interval on the trigger's start time, so the fortnight belongs to the trigger and keeps its phase — which 3.x's reading never did, counting whole weeks from wherever the search happened to start, so that a misfire, a restart, a failover or a dashboard query recomputed it from a different day and moved it.

The value after the / has to be a whole number from 1 to 7, the same one the numeric spelling takes. MON/X and SUN/9 are rejected, and the message says both what a step has to be and where the fortnight lives now.

Macros

The @ macros are the ones Unix cron has had since Vixie's, and they mean the same thing here:

MacroExpands toMeaning
@yearly, @annually0 0 0 1 1 ?Midnight on 1 January
@monthly0 0 0 1 * ?Midnight on the 1st of every month
@weekly0 0 0 ? * SUNMidnight every Sunday
@daily, @midnight0 0 0 * * ?Midnight every day
@hourly0 0 * * * ?The top of every hour

A macro needs no dialect and no opt-in, so it works wherever an expression string is read - in code, in an XML scheduling file's <cron-expression>@daily</cron-expression>, in the dashboard's expression box and over the HTTP API:

ITrigger trigger = TriggerBuilder.Create()
    .WithIdentity("nightly")
    .WithCronSchedule("@daily") // stored, and shown, as "0 0 0 * * ?"
    .Build();

The expansion is what gets stored: a trigger written with @daily reports its expression as 0 0 0 * * ?.

@reboot is rejected by name - a scheduler has no reboot to fire on, so schedule the work at startup instead - and any other @name is rejected with the list above. There is deliberately no @every_minute or @every_second: 0 * * * * ? is already short, and where the point is to spread load, H does it deterministically.

The Unix five-field form

A cron expression copied from a crontab, a Kubernetes CronJob or almost any online generator has five fields rather than six: it has no seconds field, and it numbers the days of the week 0-7 from Sunday rather than 1-7. Quartz reads that form when you ask it to, with CronFormat.Unix:

// "at 04:30 on Mondays", written the way crontab writes it
CronExpression expression = CronExpression.Parse("30 4 * * 1", CronFormat.Unix);

// ...and held the way Quartz writes it: "0 30 4 ? * MON"
string canonical = expression.CronExpressionString;

The format is a way of reading the string, and that is all it is. There are three doors - CronExpression.Parse, CronExpression.TryParse and CronScheduleBuilder.Create - and past them the expression is an ordinary CronExpression:

ITrigger trigger = TriggerBuilder.Create()
    .WithIdentity("weekday-report")
    .WithSchedule(CronScheduleBuilder.Create("15 10 * * 1-5", CronFormat.Unix))
    .Build();

// WithCronSchedule has no format overload; compose one when you need its other options
ITrigger composed = TriggerBuilder.Create()
    .WithIdentity("weekday-report-2")
    .WithCronSchedule(CronExpression.Parse("15 10 * * 1-5", CronFormat.Unix))
    .Build();

A time zone composes the same way: CronExpression.Parse(s, CronFormat.Unix).WithTimeZone(tz).

This is the one dialect an online tool describes reliably: crontab.guru explains a five-field expression field by field. Read what it tells you about the string, then hand the string to CronFormat.Unix rather than translating it by hand.

CrontabRead asMeaning
30 4 * * 10 30 4 ? * MON04:30 every Monday
0 12 1 * *0 0 12 1 * ?Noon on the 1st of every month
* * * * *0 * * * * ?Every minute
15 10 * * 1-50 15 10 ? * MON-FRI10:15 on weekdays
0 0 * * 0-60 0 0 * * ?Midnight every day - 0-6 is the whole week
0 0 * * 5-10 0 0 ? * FRI-MONMidnight Friday through Monday
0 0 13 * 50 0 0 13 * FRIThe 13th and every Friday - both fields name days

Two things differ between the dialects and nothing else does. The layout: five fields, minutes first, with no seconds and no year. The day-of-week numbering: 0-7 with both 0 and 7 meaning Sunday, so 1-5 is Monday to Friday as it is in crontab, and 5 is Friday rather than the Thursday the same digit means in a Quartz expression. Everything above is one grammar - L, W and # all work inside the five-field layout, and L alone in day-of-week still means Saturday, because it is not a number and so has nothing to renumber.

H composes with the Unix form

CronScheduleBuilder.Create(expression, CronFormat.Unix) reads an H in the five-field form: it rewrites to the Quartz form first and then defers the hash to the trigger's identity, so Create("H 4 * * 1", CronFormat.Unix) on a trigger identified as nightly comes out as 0 13 4 ? * MON. ParseWithHash and TryParseWithHash take a format beside the hash key and run the same rewrite first, so ParseWithHash("H 4 * * 1", CronFormat.Unix, "nightly") is that same expression without a trigger to hang it on. Because the rewrite runs first, an H in a five-field day-of-week is hashed over Quartz's 1-7 rather than crontab's 0-7 and can never land on a day the renumbering would have moved.

CronExpression.Parse and TryParse take a format but no hash key, so there is nothing for H to hash against. ResolveHash still takes a hash key but no format: it answers with a string rather than an expression, so it stays a Quartz-form operation, and a five-field expression is rejected there with the six-field advice. Resolve through ParseWithHash instead.

Warning

The expression is normalised to the canonical Quartz form, and the original text is not recoverable. CronExpressionString, the dashboard, the HTTP API and QRTZ_CRON_TRIGGERS.CRON_EXPRESSION all show 0 30 4 ? * MON for a trigger written as 30 4 * * 1. That is deliberate: a stored string that parses only under a flag the store does not persist would be a trap, so there is no format column and there will not be one. It is the same trade as the uppercasing that has always turned mon-fri into MON-FRI.

The consequence is that the format is a parse-time argument only: the XML schema, the HTTP API and the dashboard do not take one, so a five-field expression is something you write in C#, not something you store. The macros above have no such limit.

There is no auto-detection, and the field count alone does not choose the dialect. Letting it would make a dropped field silent: 0 0 12 * * ? without its month field is 0 0 12 * ?, a well-formed crontab line meaning midnight on the 12th rather than noon every day. The same digit also names a different day in each dialect. So Quartz asks, and the error a five-field expression gets when nobody asked names the method that does read it.

An expression copied from Spring

A Spring expression with a numeric day-of-week schedules the wrong day

Spring's @Scheduled(cron = …) is six fields with seconds first, exactly the shape of a Quartz expression — so Quartz accepts it without complaint. But Spring numbers the days of the week the Unix way, 0-7 with both 0 and 7 meaning Sunday, where Quartz numbers them 1-7 starting at Sunday. Every numeric day therefore reads one day earlier than it was written, silently, and has on every version of Quartz ever shipped.

@Scheduled(cron = …)Spring firesQuartz reads it as
0 0 9 * * 109:00 on Mondays09:00 on Sundays
0 0 9 * * 1-509:00 Monday to Friday09:00 Sunday to Thursday
0 0 9 * * 609:00 on Saturdays09:00 on Fridays
0 0 9 * * MON-FRI09:00 Monday to Friday09:00 Monday to Friday

Write the day as a name. SUN, MON, TUE, WED, THU, FRI and SAT mean the same day in Spring, in crontab and in Quartz, and Spring accepts them too — so a named expression is the one that survives being pasted in either direction. The last row above is the same schedule under both readings.

There is no CronFormat.Spring, and there is no detection of one either. The two six-field dialects are the same shape, so nothing about the string can tell them apart — which is the structural reason the format is stated rather than sniffed. A CronFormat.Spring member can be added later without breaking anything, and it is filed for 4.1; auto-detection could never be added, because by then the existing reading of a six-field expression is the one people's schedules depend on.

If your expressions came from another .NET cron library

Cronos and NCrontab are crontab-derived, and an expression written for either parses here — six fields have the same shape. Almost all of them also fire at the same instants. Two things differ, and both are silent, because the expression is perfectly valid Quartz cron; it simply means something else.

The day-of-week numbering. Quartz numbers 1-7 with Sunday 1; those libraries number 0-6 with Sunday 0, and crontab itself 0-7 with both ends Sunday. So 0 0 2 * * 1 is 02:00 on Sunday here and 02:00 on Monday there — the same divergence a Spring expression has, and the same answer: write the day as a name. SUN through SAT mean the same day in every one of these dialects.

Both day fields restricted. Quartz fires on the union of day-of-month and day-of-week, which is crontab's rule; Cronos takes their intersection. 0 0 2 5 * MON is "the 5th, and every Monday" here and "the 5th, if it is a Monday" there. Putting ? in one of the two fields says which one is in charge and settles it.

Everything else carries across. Searching from 2026-08-21T00:00:00Z, against Cronos 0.11.0:

ExpressionQuartzCronos
0 0 2 * * MON2026-08-24 02:002026-08-24 02:00same
0 0 7 * * *2026-08-21 07:002026-08-21 07:00same
0 0 */1 * * *2026-08-21 01:002026-08-21 01:00same
0 */15 * * * *2026-08-21 00:152026-08-21 00:15same
0 30 6 * * MON,TUE,WED,THU,FRI2026-08-21 06:302026-08-21 06:30same
0 0 2 * * 12026-08-23 (Sunday)2026-08-24 (Monday)differ
0 0 2 5 * MON2026-08-24 (union)2026-10-05 (intersection)differ

A five-field expression from one of those libraries is a different matter: CronFormat.Unix reads it and renumbers the days for you, so CronExpression.Parse("0 2 * * 1", CronFormat.Unix) is Monday, as its author meant. See The Unix five-field form.

Upgrading from 3.x with such an expression in the database

3.x required ? in exactly one day field, so an expression with two restricted day fields was rejected on the way in. It stores and fires on 4.0. The migration guide has an audit for finding the two shapes above among the expressions you already hold: If your expressions came from another cron library.

H (hash) for load distribution

The H symbol (for "hash") can be used in place of a specific value to spread scheduled tasks evenly across time. When many triggers share an identical cron expression such as 0 0 0 * * ? (midnight daily), they all fire simultaneously, causing resource spikes.

H resolves to a deterministic value derived from the trigger's identity (name and group). The value stays stable as long as the trigger identity doesn't change, but different triggers get different values, spreading load across the allowed range.

Syntax

ExpressionMeaning
HHash value within the full range of the field
H(0-7)Hash value constrained to the range 0 through 7
H/15Hash-derived offset, then repeat every 15 (e.g., 7, 22, 37, 52)
H(0-29)/10Hash-derived offset in 0-29, then repeat every 10 (e.g., 3, 13, 23)

H can appear in comma-separated lists alongside fixed values (e.g., H,30,45).

H is not supported in the Year field, and cannot be combined with L, W, or #.

Hash examples

ExpressionDescription
0 H H * * ?Once per day at a hash-derived hour and minute
0 H H(0-7) * * ?Once per day between midnight and 7:59 AM
0 H/15 * * * ?Every 15 minutes, starting from a hash-derived offset
H H H * * ?Once per day at a unique second, minute, and hour

Usage with TriggerBuilder

When using H through the builder API, the trigger identity is used as the hash seed. You must call WithIdentity() so the hash is derived from a stable, meaningful name rather than a random GUID:

ITrigger trigger = TriggerBuilder.Create()
    .WithIdentity("nightly-cleanup")
    .WithCronSchedule("0 H H(0-7) * * ?")
    .Build();

You can also provide an explicit hash key, which does not require a trigger identity. The key rides on the CronExpression, and WithCronSchedule takes one directly:

ITrigger trigger = TriggerBuilder.Create()
    .WithCronSchedule(CronExpression.ParseWithHash("0 H H(0-7) * * ?", "nightly-cleanup"))
    .Build();

Or resolve the expression on its own with CronExpression.ParseWithHash, which is the parse the builder overload does for you. CronExpression.TryParseWithHash is the non-throwing form, for a key and an expression that both came from somewhere you do not control. Both take a CronFormat as their second argument when the expression is not written the Quartz way:

CronExpression expr = CronExpression.ParseWithHash("0 H H(0-7) * * ?", "nightly-cleanup");

Tips

CronExpressionString returns the resolved expression (e.g., "0 23 3 * * ?") after H tokens are replaced with their computed values. This resolved form is what gets persisted to the database, ensuring stability across scheduler restarts.

Building cron expressions programmatically

When a schedule is assembled from user input - for example a scheduling UI that offers dropdowns instead of a free-form cron field - you can compose the expression with the fluent CronExpressionBuilder instead of concatenating strings:

ITrigger trigger = TriggerBuilder.Create()
    .WithIdentity("myTrigger")
    .WithCronSchedule(CronExpressionBuilder.Create()
        .WithSecond(0)
        .WithMinuteIncrements(0, 15) // every 15 minutes
        .WithHourRange(8, 17)        // between 8:00 and 17:59
        .OnWeekdays())               // "0 0/15 8-17 ? * MON-FRI"
    .Build();

WithCronSchedule accepts the builder (or a built CronExpression) directly, so the chain closes without naming CronScheduleBuilder; call Build() yourself when you want the CronExpression as a value.

Each field offers a single value, list, range and increment form (e.g. WithHour, WithHours, WithHourRange, WithHourIncrements). A schedule that fires once a day sets three of those fields to say one thing, so AtTime sets them together from a TimeOnly — add the days it applies to beside it:

CronExpressionBuilder.Create().AtTime(new TimeOnly(9, 30));            // "0 30 9 ? * *"

CronExpressionBuilder.Create()
    .AtTime(new TimeOnly(9, 30))
    .WithDaysOfWeek(DayOfWeek.Monday, DayOfWeek.Thursday);            // "0 30 9 ? * MON,THU"

CronExpressionBuilder.Create()
    .AtTime(new TimeOnly(9, 30))
    .WithDayOfMonth(15);                                              // "0 30 9 15 * ?"

Cron resolves to a whole second, so any sub-second part of the TimeOnly is ignored.

The special characters are available through dedicated methods:

CronExpressionBuilder.Create().OnLastDayOfMonth();                         // "* * * L * ?"
CronExpressionBuilder.Create().OnNearestWeekdayOfMonth(15);                // "* * * 15W * ?"
CronExpressionBuilder.Create().OnNthDayOfWeekOfMonth(DayOfWeek.Friday, 3); // "* * * ? * FRI#3"
CronExpressionBuilder.Create().OnLastDayOfWeekOfMonth(DayOfWeek.Friday);   // "* * * ? * FRIL"

A few rules to be aware of:

  • Unconfigured fields default to * (every value).
  • Each field can be configured only once; configuring it again throws InvalidOperationException.
  • One expression carries one day field: the builder renders the unused one as ? and throws if you configure both. That is the builder's own rule rather than cron's, because an expression naming both day fields fires on the union of the two (0 15 10 1,2,3 * MON,FRI), which CronExpression.Parse accepts - so write that one as text.
  • Values are validated eagerly against each field's allowed range, and Build() returns a fully validated CronExpression; use ToString() if you only need the expression string.
  • Days of the week are emitted using their textual names (MON, FRI, ...), so the produced expressions stay unambiguous across cron dialects that number weekdays differently.

Checking an expression

An expression you were given - by a colleague, by an online generator, or by a configuration file written years ago - is worth putting through the parser before it reaches a scheduler. CronExpression.TryParse answers whether Quartz.NET can read it at all, and GetNextValidTimeAfter answers the more useful question of what it actually means:

// does Quartz.NET accept it?
if (!CronExpression.TryParse("0 0/15 8-17 ? * MON-FRI", out CronExpression? expression))
{
    throw new ArgumentException("Quartz.NET cannot read that expression");
}

// what does it mean? - the next five times it fires, in the expression's own time zone
DateTimeOffset after = DateTimeOffset.UtcNow;
for (int i = 0; i < 5; i++)
{
    DateTimeOffset? fireTime = expression.GetNextValidTimeAfter(after);
    if (fireTime is null)
    {
        break;
    }

    Console.WriteLine(TimeZoneInfo.ConvertTime(fireTime.Value, expression.TimeZone));
    after = fireTime.Value;
}

Printing the fire times is what catches a misread field, because a schedule that is off by a day or an hour looks perfectly plausible as a string and obvious as a list of dates. It is also the only check that covers this dialect in full: H, a range that wraps (22-2, FRI-MON), MON/2 and the way the two day fields combine are each a point where Quartz.NET parts company with Java Quartz, with Unix cron, or with both - and an external tool implements none of them.

A few things to know about the loop above:

  • GetNextValidTimeAfter returns null when the expression has no further fire time - an expression naming a year that has passed, for instance - so the loop stops rather than repeating.
  • The times come back as DateTimeOffset, and TimeZone is the zone the expression is read in (the local zone unless you passed one). Converting before printing is what makes daylight saving time visible.
  • CronExpression.Parse is the same thing for code that would rather throw than branch, and both take a CronFormat.

Examples

Here are some full examples:

ExpressionMeaning
0 0 12 * * ?Fire at 12pm (noon) every day
0 15 10 ? * *Fire at 10:15am every day
0 15 10 * * ?Fire at 10:15am every day
0 15 10 * * ? *Fire at 10:15am every day
0 15 10 * * ? 2005Fire at 10:15am every day during the year 2005
0 * 14 * * ?Fire every minute starting at 2pm and ending at 2:59pm, every day
0 0/5 14 * * ?Fire every 5 minutes starting at 2pm and ending at 2:55pm, every day
0 0/5 14,18 * * ?Fire every 5 minutes starting at 2pm and ending at 2:55pm, AND fire every 5 minutes starting at 6pm and ending at 6:55pm, every day
0 0-5 14 * * ?Fire every minute starting at 2pm and ending at 2:05pm, every day
0 10,44 14 ? 3 WEDFire at 2:10pm and at 2:44pm every Wednesday in the month of March.
0 15 10 ? * MON-FRIFire at 10:15am every Monday, Tuesday, Wednesday, Thursday and Friday
0 15 10 15 * ?Fire at 10:15am on the 15th day of every month
0 15 10 L * ?Fire at 10:15am on the last day of every month
0 15 10 L-2 * ?Fire at 10:15am on the 2nd-to-last last day of every month
0 15 10 ? * 6LFire at 10:15am on the last Friday of every month
0 15 10 ? * 6L 2002-2005Fire at 10:15am on every last Friday of every month during the years 2002, 2003, 2004 and 2005
0 15 10 ? * 6#3Fire at 10:15am on the third Friday of every month
0 0 12 1/5 * ?Fire at 12pm (noon) every 5 days every month, starting on the first day of the month.
0 11 11 11 11 ?Fire every November 11th at 11:11am.
0 15 10 1,2,3 * MON,FRIFire at 10:15am on the 1st, 2nd, 3rd of the month, and every Monday and Friday
H H H * * ?Fire once per day at a hash-derived second, minute, and hour (spread across triggers)
0 H H(0-7) * * ?Fire once per day between midnight and 7:59 AM, at a hash-derived time
0 H/15 * * * ?Fire every 15 minutes, starting from a hash-derived offset

Tips

Pay attention to the effects of ? and * in the day-of-week and day-of-month fields. A day field written exactly * or ? names no days, so it restricts nothing and the other day field decides: 0 15 10 1 * * fires on the 1st of the month, and 0 15 10 * * MON fires every Monday. Only when both fields name days does the expression fire on the union of the two, as 0 15 10 1,2,3 * MON,FRI above does; when neither names days, every day matches. This is the Unix crontab(5) rule - some other cron implementations intersect the two fields instead, so an expression copied from one of those fires more often here than it did there.

Daylight saving time

A cron expression names a wall-clock time, and a daylight saving transition is exactly the event that makes a wall clock ambiguous or missing. Nothing is skipped and nothing is fired twice, but it is worth knowing which instant is chosen, and the answer depends on whether the expression names a fixed time of day or an interval.

A fixed-time expression is one whose second, minute and hour fields are plain values or comma lists of plain values - 0 30 2 * * ?, 0 0,30 2 * * ?:

  • A wall-clock time the clocks skip fires once, at the end of the gap - the instant the clocks moved. A daily 0 30 2 * * ? over a 02:00-03:00 spring-forward gap fires at 03:00. Every wall clock the gap swallowed names that one instant, so an expression matching several of them still fires once. This is the instant the expression itself matches: IsSatisfiedBy agrees with the fire time, which is what makes it the right answer.
  • A wall-clock time that occurs twice on a fall-back day fires once, at the first of the two occurrences.

An interval expression - one with a wildcard, a step or a range in the second, minute or hour field, such as 0 * * * * ? or 0 0/30 * * * ? - keeps firing through the repeated hour, so both passes of it run. Over a spring-forward gap the gap-end rule shows as an extra fire rather than a moved one: 0 30 * * * ? fires at 03:00 for the occurrence the gap swallowed and again at 03:30 for the next hour's.

A CronCalendar written over the skipped hour excludes the gap's end for the same reason.

Quartz 3.x behaves differently

On 3.x a skipped time is shifted forward by the transition's delta instead - the daily 0 30 2 * * ? above fires at 03:30, an instant its own expression does not match - and an interval expression fires the repeated hour only once, so an "every minute" schedule silently loses an hour of real time each autumn.

Whatever the schedule, name the time zone: an expression with none uses TimeZoneInfo.Local, which is the developer's machine in development and very often UTC in a container. Daylight saving, clock changes and cluster skew covers the choice of trigger family, and the FAQ has the longer treatment.

Help us by improving this page!
Last Updated: 9/19/26, 9:30 PM
Contributors: Marko Lahma, Claude Opus 5 (1M context)
Prev
JSON Configuration
Next
Multi-Tenancy