Quartz.NET Cron Expressions: Seven Fields, L, W, #, and Time Zones
Contents
A Quartz.NET cron expression has six required fields, seconds first, and an optional seventh field for the year. Exactly one of day of month and day of week must be ?, so daily at 02:30 is 0 30 2 * * ? and weekdays at 09:00 is 0 0 9 ? * MON-FRI. A trigger with no time zone runs in TimeZoneInfo.Local, so name the zone with InTimeZone.
This guide covers Quartz.NET 3.22.9, the current 3.x release on NuGet (checked October 30, 2026). Every parsing rule below is from CronExpression.cs at that tag. Quartz.NET 4.x is also on NuGet (4.6.2, checked October 30, 2026); the daylight saving section notes where 4.x behaves differently, and CronRadar.Quartz 0.1.0 targets 3.x. For cron syntax in general, see cron expression syntax. For the same reference on Hangfire, see Hangfire cron expressions.
The Seven Fields, Seconds First#
┌───────────── second (0-59)
│ ┌───────────── minute (0-59)
│ │ ┌───────────── hour (0-23)
│ │ │ ┌───────────── day of month (1-31; also ? L W)
│ │ │ │ ┌───────────── month (1-12 or JAN-DEC)
│ │ │ │ │ ┌───────────── day of week (1-7 or SUN-SAT; 1 is Sunday; also ? L #)
│ │ │ │ │ │ ┌───────────── year (optional)
│ │ │ │ │ │ │
0 30 2 * * ? *The class documentation lists "6 required fields and one optional field". Points from the source that catch people coming from Unix cron:
- Seconds come first. A five-field Unix expression such as
30 2 * * *fills only five fields, and the parser throwsUnexpected end of expression.. More than seven fields throwsInvalid expression has too many terms:followed by the expression. - Day of week runs 1 to 7, with 1 as Sunday. In Unix cron and CronRadar, Sunday is 0 and Monday is 1. Names avoid the off-by-one:
MON-FRImeans the same thing in both. - Steps start from a value.
0/15in the minute field means 0, 15, 30, and 45, and*before/is the same as starting at 0. The docs warn that7/6in the month field "only turns on month 7, it does NOT mean every 6th month". - Names and special characters aren't case-sensitive, and wrapping ranges such as
22-2orNOV-FEBare supported. - The year field defaults to every year when you leave it out. The parser fills it with
*. The class documentation gives its range as 1970 to 2199, but 3.22.9 returns no fire time afterMaxYear, the current year plus 100, so in 2026 a year after 2126 never fires.
Quartz.NET 3.22.9 also accepts H (hash) tokens, which CronScheduleBuilder.cs resolves from the trigger's identity. They don't exist in five-field cron, so they are out of scope here.
Why One Day Field Must Be ?#
? means "no specific value" and is allowed only in the day-of-month and day-of-week fields. After reading the fields, BuildExpression checks that exactly one of them is ?. If neither is, or both are, it throws:
Support for specifying both a day-of-week AND a day-of-month parameter is not implemented.That rules out the most common conversion mistake. 0 0 2 * * *, a six-field version of 0 2 * * *, restricts neither day field with ? and fails, and so does 0 0 2 ? * ?. Put ? in the field you aren't using:
| You mean | Wrong | Right |
|---|---|---|
| Every day at 02:00 | 0 0 2 * * * |
0 0 2 * * ? |
| Weekdays at 09:00 | 0 0 9 * * MON-FRI |
0 0 9 ? * MON-FRI |
| 15th at 06:00 | 0 0 6 15 * * |
0 0 6 15 * ? |
Because Quartz never restricts both day fields at once, it has no equivalent of Unix cron's "either day matches" rule, or of the "both must match" rule that Cronos and CronRadar use. "Friday the 13th" can't be written as one Quartz expression.
Two more day-of-week checks throw before that: L combined with a list (Support for specifying 'L' with other days of the week is not implemented), and more than one # (Support for specifying multiple "nth" days is not implemented.).
L, W, LW, L-n, and ##
| Expression | Fires at |
|---|---|
0 0 23 L * ? |
23:00 on the last day of the month |
0 0 23 L-3 * ? |
23:00 on what the docs call "the third-to-last day" |
0 0 9 15W * ? |
09:00 on the weekday nearest the 15th |
0 0 18 LW * ? |
18:00 on the last weekday of the month |
0 0 9 ? * 6L |
09:00 on the last Friday of the month |
0 0 9 ? * L |
09:00 every Saturday (L alone means 7, SAT) |
0 0 9 ? * 2#1 |
09:00 on the first Monday of the month |
0 0 9 ? * 6#3 |
09:00 on the third Friday of the month |
The rules, from the class documentation and the parser:
Lmeans something different in each day field. In day of month it is the last day: 31 in January, 28 in February in a non-leap year. In day of week, by itself it is just Saturday; after a day number,6Lis the last Friday. The docs warn against combiningLwith lists or ranges, "as you'll get confusing/unexpected results".L-ncounts back from the last day. The offset must be 30 or less, or the parser throwsOffset from last day must be <= 30. 3.22.9 also parsesL-nWandLW-n.Wmoves to the nearest weekday but stays in the month. If the 15th is a Saturday,15Wfires Friday the 14th; if it is a Sunday, Monday the 16th.1Won a Saturday the 1st fires Monday the 3rd, because it won't jump back into the previous month. The class documentation saysWworks only on a single day, not a range or list, but the 3.22.9 parser tracks eachnWentry separately, so a list such as1W,15Wparses and each entry shifts its own day.#takes 1 to 5. Anything else throwsA numeric value between 1 and 5 must follow the '#' option. A fifth occurrence that doesn't exist that month means "no firing will occur that month", and only one#is allowed in the field:3#1,6#3is invalid.
For the five-field workaround for last-day schedules, see running cron on the last day of the month.
Common Schedules as Quartz Strings#
| Schedule | Quartz expression | Five-field equivalent |
|---|---|---|
| Every 30 seconds | 0/30 * * * * ? |
None (seconds) |
| Every 5 minutes | 0 0/5 * * * ? |
*/5 * * * * |
| Hourly at :15 | 0 15 * * * ? |
15 * * * * |
| Daily at 02:30 | 0 30 2 * * ? |
30 2 * * * |
| Weekdays at 09:00 | 0 0 9 ? * MON-FRI |
0 9 * * 1-5 |
| 15th of the month at 06:00 | 0 0 6 15 * ? |
0 6 15 * * |
| First Monday of the month at 09:00 | 0 0 9 ? * 2#1 |
0 9 1-7 * 1 (CronRadar only) |
| Last day of the month at 23:00 | 0 0 23 L * ? |
None (L) |
| Every day in 2027 at 02:30 | 0 30 2 * * ? 2027 |
None (year) |
0 9 1-7 * 1 means the first Monday only to a parser that requires both day fields to match, which CronRadar does (schedules). Linux cron runs it on the 1st through the 7th and on every Monday.
CronScheduleBuilder Helpers and the Strings They Build#
The helpers in CronScheduleBuilder.cs build a string with CronExpressionBuilder, which renders an unset day of month as ?, an unset day of week as ? when a day of month is set and * otherwise, and day-of-week values as SUN through SAT:
| Helper | Expression |
|---|---|
DailyAtHourAndMinute(2, 30) |
0 30 2 ? * * |
WeeklyOnDayAndHourAndMinute(DayOfWeek.Friday, 17, 30) |
0 30 17 ? * FRI |
AtHourAndMinuteOnGivenDaysOfWeek(9, 0, DayOfWeek.Monday, DayOfWeek.Wednesday) |
0 0 9 ? * MON,WED |
MonthlyOnDayAndHourAndMinute(15, 6, 0) |
0 0 6 15 * ? |
CronScheduleBuilder.CronSchedule(string) validates the expression as soon as you call it, through CronExpression.ValidateExpression, which constructs a CronExpression. A bad string throws FormatException with one of the messages above while you build the trigger, not at fire time. The helpers check their arguments first and throw ArgumentException for an hour, minute, or day out of range.
Set the Time Zone With InTimeZone#
A cron trigger whose time zone was never set reports TimeZoneInfo.Local (CronTriggerImpl.cs); why a Quartz.NET job is not firing covers debugging a trigger that runs at the host's local time. InTimeZone sets the zone on the builder's CronExpression, and Build() copies it onto the trigger:
using Quartz;
IJobDetail job = JobBuilder.Create<NightlyReportJob>()
.WithIdentity("nightly-report", "reports")
.Build();
// 02:30 UTC every day, whatever the host's zone.
ITrigger utcTrigger = TriggerBuilder.Create()
.WithIdentity("nightly-report-utc", "reports")
.ForJob(job)
.WithSchedule(CronScheduleBuilder
.CronSchedule("0 30 2 * * ?")
.InTimeZone(TimeZoneInfo.Utc))
.Build();
// 09:00 New York time on weekdays, following daylight saving time.
ITrigger localTrigger = TriggerBuilder.Create()
.WithIdentity("weekday-digest-ny", "reports")
.ForJob(job)
.WithSchedule(CronScheduleBuilder
.CronSchedule("0 0 9 ? * MON-FRI")
.InTimeZone(TimeZoneInfo.FindSystemTimeZoneById("America/New_York")))
.Build();The zone ID has to resolve on every host that runs the scheduler; the Hangfire guide has a table of which .NET runtimes resolve IANA and Windows IDs. Missed fire times, including the ones a restart causes, are handled by the misfire instruction rather than the expression: see Quartz.NET misfire instructions.
Daylight Saving Time in Quartz.NET 3.x#
The FAQ says a cron trigger "is never skipped by a transition, and never fires twice for one scheduled occurrence", and splits expressions into two kinds. A fixed-time expression has plain values or lists in the second, minute, and hour fields, such as 0 15 2 * * ?. An interval expression has a wildcard, step, or range in one of them.
| Transition | Expression type | 3.x | 4.x |
|---|---|---|---|
| Spring forward, time missing | Fixed time | Fires once, shifted forward by the gap: 02:15 becomes 03:15 | Fires once at the end of the gap, 03:00 |
| Fall back, time repeated | Fixed time | Fires once, at the first occurrence | Same as 3.x |
| Fall back, hour repeated | Interval | Fires the repeated hour once; an hour passes with no firing | Fires in both passes |
Spring forward, 0 30 * * * ? |
Interval | Resumes at the shifted 03:30 and fires once | Fires at 03:00 and again at 03:30 |
For 0 30 2 * * ? in America/New_York, clocks jump from 02:00 EST to 03:00 EDT on March 14, 2027. Quartz 3.x fires at 02:30 EST (07:30 UTC) on March 13, at 03:30 EDT (07:30 UTC) on March 14, and at 02:30 EDT (06:30 UTC) on March 15. The local run time is fixed and the UTC time moves, which is what anything watching in UTC sees. Cron and daylight saving time covers the general problem.
Translating a Quartz Expression to Five-Field UTC#
CronRadar reads schedules as standard five-field cron evaluated in UTC. Its schedule docs reject a seconds field, a year field, ?, L, W, #, shortcuts such as @daily, and 7 for Sunday. To rewrite a Quartz expression:
- Drop the seconds field. It must be a single value. A step or list there, such as
0/30, runs more than once a minute and has no five-field equivalent. - Drop the year field. A restricted year has no equivalent.
- Replace
?with*. - Fix day-of-week numbers. Subtract 1 (Quartz
2-6is1-5), or keep the names. - Rewrite
L,W,LW,L-n, and#. There is no five-field form. Schedule the job on every candidate date, such as28-31for the last day, and let the job decide whether to do the work, checking in on every run, including the ones that exit early.2#1can become1-7in day of month with1in day of week, but only for a parser that matches both fields. - Convert the hours to UTC. With
InTimeZone(TimeZoneInfo.Utc)there is nothing to do. For a zone with daylight saving time, the schedule docs say to use the earlier of the two UTC times and a grace period of at least an hour plus the job's usual duration: for 02:30 New York,30 6 * * *with at least 3,600 seconds of grace.
Minute and hour steps carry over unchanged: CronRadar accepts 0/5 as well as */5.
On CronRadar.Quartz 0.1.0, make that translation yourself. The published package sends a cron trigger's CronExpressionString unchanged, from both its startup reconciliation and its job listener's start signal (CronRadarQuartzSchedule.cs). Every Quartz cron expression has a seconds field, so CronRadar rejects that schedule: discovery sync lists the monitor in errors, and a check-in carrying it gets 400 INVALID_REQUEST with Invalid schedule format. The conversion described in the Quartz integration guide belongs to the pending 0.1.1 release. 0.1.0 has no schedule override: [CronRadar] sets only the monitor key (CronRadarAttribute.cs). Until 0.1.1 ships, mark a cron-triggered job [SkipMonitor], which keeps both the reconciliation and the job listener away from it, and report the job yourself with its five-field UTC schedule: through WrapAsync in the base CronRadar 1.0.13 SDK, which takes the schedule as its schedule argument (CronRadar.cs), or through HTTP check-ins with Basic auth. Simple triggers that repeat are converted to an approximate five-field schedule in 0.1.0 already.
Once the schedule is in place, CronRadar marks a job warning when its run is late and critical when the grace period passes without one or the run reports failed, and alerts by email, Slack, webhook, Discord, or PagerDuty. Quartz.NET monitoring covers what to watch beyond the schedule, and why a Quartz.NET job is not firing covers a trigger that parses but never runs.
Frequently Asked Questions#
What cron format does Quartz.NET use?#
Six required fields, seconds first: second, minute, hour, day of month, month, day of week, with an optional seventh field for the year. Day of week runs 1 to 7 with Sunday as 1, and exactly one day field must be ?.
Why does Quartz say "Support for specifying both a day-of-week AND a day-of-month parameter is not implemented"?#
Neither day field is ?, or both are. Put ? in the field you aren't restricting: 0 0 2 * * ? for every day, 0 0 9 ? * MON-FRI for weekdays.
How do I run a Quartz.NET job every 5 minutes?#
Use 0 0/5 * * * ?. The seconds field is 0, the minute step starts at :00, and day of week is ?. The five-field equivalent is */5 * * * *.
What time zone does a Quartz.NET cron trigger use?#
TimeZoneInfo.Local unless you call InTimeZone on the CronScheduleBuilder. Set it explicitly, TimeZoneInfo.Utc for jobs that should stay evenly spaced, so the trigger means the same thing on every host.
Can a Quartz.NET expression with L, W, or # be monitored?#
Not as written in standard five-field cron. Schedule the job on every candidate date, such as 0 0 23 28-31 * ? instead of 0 0 23 L * ?, have the job exit early on the other days, and report the five-field form, 0 23 28-31 * * for a trigger in UTC, with a check-in on every run, including the ones that exit early. CronRadar.Quartz 0.1.0 forwards the Quartz string unchanged, so send that schedule through the base CronRadar SDK or HTTP check-ins.
Put this guide to work
Connect your Quartz.NET job to monitoring
Follow the current integration guide, check the release limitations, and verify a real run and alert delivery.
NuGet currently installs CronRadar.Quartz 0.1.0. MonitorAll discovers jobs, but this release sends a cron trigger's six-field expression unchanged and CronRadar accepts only five-field cron, so only jobs on repeating simple triggers are monitored; translating cron triggers requires the pending 0.1.1 release. Every reconciliation sets each discovered monitor's grace period to CronRadar.CronRadar.DefaultGracePeriodSeconds, 60 unless set in code, replacing dashboard edits.