# Quartz.NET Cron Expressions: Seven Fields, L, W, #, and Time Zones

By Dan Ochoa · Published 2026-10-30 · Updated 2026-10-30
Canonical: https://cronradar.com/blog/quartz-net-cron-expressions

> Quartz.NET 3.x cron reference: seconds-first fields, the ? rule, L, W, LW, L-n, and #, CronScheduleBuilder strings, InTimeZone, DST, and five-field UTC.

**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](https://www.nuget.org/packages/Quartz/3.22.9) (checked October 30, 2026). Every parsing rule below is from [CronExpression.cs](https://github.com/quartznet/quartznet/blob/v3.22.9/src/Quartz/CronExpression.cs) at that tag. Quartz.NET 4.x is also on [NuGet](https://www.nuget.org/packages/Quartz/4.6.2) (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](https://cronradar.com/blog/cron-expression). For the same reference on Hangfire, see [Hangfire cron expressions](https://cronradar.com/blog/hangfire-cron-time-zones).

## The Seven Fields, Seconds First

```text
┌───────────── 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 throws `Unexpected end of expression.`. More than seven fields throws `Invalid 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-FRI` means the same thing in both.
- **Steps start from a value.** `0/15` in the minute field means 0, 15, 30, and 45, and `*` before `/` is the same as starting at 0. The docs warn that `7/6` in 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-2` or `NOV-FEB` are 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 after `MaxYear`, 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](https://github.com/quartznet/quartznet/blob/v3.22.9/src/Quartz/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:

```text
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](https://github.com/HangfireIO/Cronos/blob/v0.11.1/README.md) 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:

- **`L` means 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, `6L` is the last Friday. The docs warn against combining `L` with lists or ranges, "as you'll get confusing/unexpected results".
- **`L-n` counts back from the last day.** The offset must be 30 or less, or the parser throws `Offset from last day must be <= 30`. 3.22.9 also parses `L-nW` and `LW-n`.
- **`W` moves to the nearest weekday but stays in the month.** If the 15th is a Saturday, `15W` fires Friday the 14th; if it is a Sunday, Monday the 16th. `1W` on a Saturday the 1st fires Monday the 3rd, because it won't jump back into the previous month. The class documentation says `W` works only on a single day, not a range or list, but the 3.22.9 parser tracks each `nW` entry separately, so a list such as `1W,15W` parses and each entry shifts its own day.
- **`#` takes 1 to 5.** Anything else throws `A 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#3` is invalid.

For the five-field workaround for last-day schedules, see [running cron on the last day of the month](https://cronradar.com/blog/cron-last-day-of-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](https://docs.cronradar.com/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](https://github.com/quartznet/quartznet/blob/v3.22.9/src/Quartz/CronScheduleBuilder.cs) build a string with [CronExpressionBuilder](https://github.com/quartznet/quartznet/blob/v3.22.9/src/Quartz/CronExpressionBuilder.cs), 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](https://github.com/quartznet/quartznet/blob/v3.22.9/src/Quartz/Impl/Triggers/CronTriggerImpl.cs)); [why a Quartz.NET job is not firing](https://cronradar.com/blog/quartz-net-job-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:

```csharp
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](https://cronradar.com/blog/hangfire-cron-time-zones) 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](https://cronradar.com/blog/quartz-net-misfire-instructions).

## Daylight Saving Time in Quartz.NET 3.x

The [FAQ](https://www.quartz-scheduler.net/documentation/faq.html#daylight-saving-time-and-triggers) 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](https://cronradar.com/blog/cron-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](https://docs.cronradar.com/schedules) reject a seconds field, a year field, `?`, `L`, `W`, `#`, shortcuts such as `@daily`, and `7` for Sunday. To rewrite a Quartz expression:

1. **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.
2. **Drop the year field.** A restricted year has no equivalent.
3. **Replace `?` with `*`.**
4. **Fix day-of-week numbers.** Subtract 1 (Quartz `2-6` is `1-5`), or keep the names.
5. **Rewrite `L`, `W`, `LW`, `L-n`, and `#`.** There is no five-field form. Schedule the job on every candidate date, such as `28-31` for 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#1` can become `1-7` in day of month with `1` in day of week, but only for a parser that matches both fields.
6. **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](https://github.com/cronradar/cronradar-quartz/blob/v0.1.0/src/CronRadar.Quartz/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](https://docs.cronradar.com/integrations/quartz) belongs to the pending 0.1.1 release. 0.1.0 has no schedule override: `[CronRadar]` sets only the monitor key ([CronRadarAttribute.cs](https://github.com/cronradar/cronradar-quartz/blob/v0.1.0/src/CronRadar.Quartz/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](https://www.nuget.org/packages/CronRadar/1.0.13) 1.0.13 SDK, which takes the schedule as its `schedule` argument ([CronRadar.cs](https://github.com/cronradar/cronradar-dotnet/blob/v1.0.13/src/CronRadar/CronRadar.cs)), or through [HTTP check-ins](https://docs.cronradar.com/api-reference) 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](https://cronradar.com/blog/quartz-net-monitoring) covers what to watch beyond the schedule, and [why a Quartz.NET job is not firing](https://cronradar.com/blog/quartz-net-job-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.
