Mert Özen Further With Every Line
Backend Background Jobs and Scheduled Jobs — #04

Cron Expressions: Unix 5-Field, 6-Field with Seconds, Quartz Format and Time Zone/DST Pitfalls

Mert Özen Nov 8, 2026 13 min 3 views
Cron Expressions: Unix 5-Field, 6-Field with Seconds, Quartz Format and Time Zone/DST Pitfalls

Unix cron has 5 fields, Quartz wants 6–7 fields starting with seconds and counts weekdays as 1=Sunday; Quartz 4 rejects "0 0 * * *" with the default parser and accepts it with CronFormat.Unix. Across DST, a daily 02:30 job runs once at 03:00 in spring and only once in autumn, while interval jobs run twice in the repeated hour. You can write your own cron scheduler on TimeProvider and test it with FakeTimeProvider.

Version note: The code in this post was built and run with .NET 10, Cronos 0.13.0 and Quartz.NET 4.7.2; the test project used xUnit 2.9.3 and Microsoft.Extensions.TimeProvider.Testing 10.11.0.

Guess First

The nightly stock report for Bookstore's warehouse in Germany runs every day at 02:30 Berlin time: 30 2 * * *. On 29 March 2026 the clocks jump from 02:00 to 03:00; on 25 October 2026 they go back from 03:00 to 02:00, and 02:00–03:00 happens twice. Does the report run on 29 March, and if so, at what time? Does it run once or twice on 25 October? And how many times does a */30 * * * * job, running every 30 minutes, run on the night of 25 October?

One more: what happens if you give Quartz.NET the same expression as 0 0 * * *?

Reading Unix Cron Field by Field

A classic Unix cron expression has five fields: minute, hour, day of month, month, day of week. 30 2 * * * means "every day of every month, every day of the week, hour 2, minute 30". Each field has four basic tools: * every value, , a list (1,15), - a range (9-17) and / a step (*/15, i.e. 0, 15, 30, 45). Day of week runs 0–6, 0 is Sunday and 1 is Monday; in the Vixie Cron tradition 7 is also Sunday.

One rule to watch when reading: if both the day-of-month and day-of-week fields are restricted, classic cron combines them with "or", not "and". 0 9 1 * 1 doesn't mean "when the 1st of the month falls on a Monday" but "on the 1st of every month and every Monday". Quartz resolves this ambiguity with the ? character; we'll get to it shortly.

For its recurring jobs, Hangfire uses the Cronos library written by the same team (embedded inside Hangfire.Core); you can also use Cronos directly in your own code. Cronos supports the standard 5 fields, an optional seconds field and the non-standard L, W and # characters.

6 Fields with Seconds and the Quartz Format

When you need second precision, a seconds field is added at the front: */30 * * * * * every 30 seconds. Cronos wants this as a separate format; if you try to parse the same expression with the standard format, the error message can mislead you, because Cronos takes the first field for minutes and reads everything shifted:

try { CronExpression.Parse("0 0 0 * * *"); }
catch (CronFormatException ex) { Console.WriteLine($"Cronos '0 0 0 * * *' (Standard) -> {ex.Message}"); }

// Cronos '0 0 0 * * *' (Standard) -> The given cron expression has an invalid format. Days of month: Value must be a number between 1 and 31 (all inclusive).

The Quartz format always starts with seconds and has 6 or 7 fields: second, minute, hour, day of month, month, day of week and an optional year. It has three important differences from Unix. First, ?: one of the day-of-month and day-of-week fields is ?, meaning "not specified". Second, special characters: L last (the last day of the month, or with 6L the last Friday of the month), W nearest weekday (15W), # the nth weekday of the month (3#2, the second Tuesday). Third, and most dangerous: Quartz numbers weekdays 1–7, and 1 is Sunday.

Quartz.NET 4 tightened its cron parser and added a Roslyn analyzer next to it. Writing a 5-field Unix expression as a constant string makes the code fail to compile:

var q = new Quartz.CronExpression("0 0 * * *");

// Program.cs(1,35): error QZ0001: '0 0 * * *' is not a valid cron expression: Cron expression '0 0 * * *' has 5 fields, which is the Unix/crontab form; Quartz cron has 6 or 7 fields with seconds first. Prepend a seconds field: "0 0 0 * * *". Note that Quartz numbers days of week 1-7 starting at Sunday where Unix cron uses 0-6, so respell numeric day-of-week values or use names (SUN, MON, ...). Or read it as written, renumbering and all, with CronExpression.Parse(expression, CronFormat.Unix).

At runtime, for example with an expression coming from configuration, the same check arrives as a FormatException. The last sentence of the message matters: Quartz 4 accepts the Unix format if you ask for it explicitly with CronFormat.Unix, and then interprets weekday numbers the Unix way too. I saw the difference in a real run:

var baseTime = new DateTimeOffset(2026, 11, 8, 0, 0, 0, TimeSpan.Zero); // a Sunday

Console.WriteLine($"Unix  '0 8 * * 1' -> {CronExpression.Parse("0 8 * * 1").GetNextOccurrence(baseTime.UtcDateTime):ddd yyyy-MM-dd HH:mm}");
Console.WriteLine($"Quartz '0 0 8 ? * 1' -> {QuartzCron.Parse("0 0 8 ? * 1").WithTimeZone(TimeZoneInfo.Utc).GetNextValidTimeAfter(baseTime):ddd yyyy-MM-dd HH:mm}");
Console.WriteLine($"Quartz Unix-format '0 8 * * 1' -> {QuartzCron.Parse("0 8 * * 1", Quartz.CronFormat.Unix).WithTimeZone(TimeZoneInfo.Utc).GetNextValidTimeAfter(baseTime):ddd yyyy-MM-dd HH:mm}");

// Unix  '0 8 * * 1' -> Mon 2026-11-09 08:00
// Quartz '0 0 8 ? * 1' -> Sun 2026-11-08 08:00
// Quartz Unix-format '0 8 * * 1' -> Mon 2026-11-09 08:00

The same 1 is Monday in Unix (9 November) and Sunday in the Quartz format (8 November). When you give Quartz a Unix expression, either use CronFormat.Unix or write names such as MON and SUN instead of numbers; names mean the same thing in both formats. Note also: Quartz 4.7.2 accepted an expression that fills both day fields, such as 0 0 12 * * MON. For clarity, still make one of them ?.

Common Expressions in Three Formats

I didn't write the table below from memory; I parsed every row with both Cronos and Quartz 4.7.2, computed the first run after 8 November 2026 00:00 UTC (Coordinated Universal Time), and checked that all four calculations gave the same result.

MeaningUnix / CronosCronos, with secondsQuartz
Every minute* * * * *0 * * * * *0 * * ? * *
Every 5 minutes*/5 * * * *0 */5 * * * *0 0/5 * ? * *
Every 30 secondsnone*/30 * * * * *0/30 * * ? * *
Every hour at :1515 * * * *0 15 * * * *0 15 * ? * *
Every day at 02:3030 2 * * *0 30 2 * * *0 30 2 * * ?
Weekdays at 09:000 9 * * 1-50 0 9 * * 1-50 0 9 ? * MON-FRI
Every 15 minutes, 09–17, weekdays*/15 9-17 * * 1-50 */15 9-17 * * 1-50 0/15 9-17 ? * MON-FRI
Mondays at 08:000 8 * * 10 0 8 * * 10 0 8 ? * 2
1st of the month at 00:000 0 1 * *0 0 0 1 * *0 0 0 1 * ?
Last day of the month at 23:000 23 L * *0 0 23 L * *0 0 23 L * ?
Last Friday of the month at 18:000 18 * * 5L0 0 18 * * 5L0 0 18 ? * 6L
Second Tuesday of the month at 10:000 10 * * 2#20 0 10 * * 2#20 0 10 ? * 3#2
Weekday nearest the 15th at 09:000 9 15W * *0 0 9 15W * *0 0 9 15W * ?

The L, # and W in the last four rows aren't part of standard Unix cron; the entries in the "Unix / Cronos" column are extensions accepted by Cronos and by Quartz's CronFormat.Unix mode. The relevant lines of the verification program:

mondays 08:00                    cronos5=11-09 08:00:00 cronos6=11-09 08:00:00 quartz=11-09 08:00:00 quartzUnix=11-09 08:00:00
last day of month 23:00          cronos5=11-30 23:00:00 cronos6=11-30 23:00:00 quartz=11-30 23:00:00 quartzUnix=11-30 23:00:00
last Friday 18:00                cronos5=11-27 18:00:00 cronos6=11-27 18:00:00 quartz=11-27 18:00:00 quartzUnix=11-27 18:00:00
2nd Tuesday 10:00                cronos5=11-10 10:00:00 cronos6=11-10 10:00:00 quartz=11-10 10:00:00 quartzUnix=11-10 10:00:00
weekday nearest the 15th 09:00   cronos5=11-16 09:00:00 cronos6=11-16 09:00:00 quartz=11-16 09:00:00 quartzUnix=11-16 09:00:00

You may have noticed that the Quartz column in the "Mondays at 08:00" row is 2; that's Quartz's 1=Sunday numbering. A second reason to write weekdays by name in Quartz.

Choosing a Time Zone: UTC or Europe/Istanbul?

A cron expression on its own describes a wall-clock rule, not an instant. "Every day at 02:30" is 02:30 on which clock? Libraries give different default answers; Hangfire assumes UTC for recurring jobs and lets you change it with RecurringJobOptions.TimeZone, while Quartz 4.7, when nothing is specified, uses QuartzSchedulerOptions.DefaultTimeZone, and if that's not set either, the machine's local time. Since most containers run in UTC, relying on local time means a job that runs at 02:30 on your dev machine runs at 05:30 in production.

Express the rule in the time zone the business lives in. "A campaign email to customers in Turkey at 09:00" is a Europe/Istanbul rule; "archive logs every hour" is a UTC rule. Turkey hasn't observed daylight saving time since 2016, so Europe/Istanbul is UTC+3 all year and immune to DST (Daylight Saving Time) pitfalls:

Istanbul offset Jan=03:00:00 Jul=03:00:00
Berlin   offset Jan=01:00:00 Jul=02:00:00

Bookstore's Berlin warehouse is a different story. And if Turkey ever goes back to summer time, code using the name Europe/Istanbul will behave correctly on its own with a tzdata update, while code using a fixed +03:00 will start running at the wrong time. Always give the time zone by its IANA (Internet Assigned Numbers Authority) name, not as an offset.

DST Transitions: The Skipped and the Repeated Hour

I asked the opening question of Cronos and Quartz 4.7.2 at the same time:

TimeZoneInfo berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");

void Show(string title, string cron, DateTimeOffset from, DateTimeOffset to)
{
    Console.WriteLine($"-- {title}: '{cron}'");
    var cronos = CronExpression.Parse(cron);
    Console.WriteLine("   Cronos: " + string.Join(", ", cronos.GetOccurrences(from, to, berlin).Select(o => o.ToString("MM-dd HH:mm zzz"))));
    var quartz = QuartzCron.Parse(cron, Quartz.CronFormat.Unix).WithTimeZone(berlin);
    var list = new List<string>();
    DateTimeOffset? t = quartz.GetNextValidTimeAfter(from.AddSeconds(-1));
    while (t is not null && t < to) { list.Add(TimeZoneInfo.ConvertTime(t.Value, berlin).ToString("MM-dd HH:mm zzz")); t = quartz.GetNextValidTimeAfter(t.Value); }
    Console.WriteLine("   Quartz: " + string.Join(", ", list));
}

var springFrom = new DateTimeOffset(2026, 3, 29, 1, 0, 0, TimeSpan.FromHours(1));
var springTo = new DateTimeOffset(2026, 3, 29, 4, 0, 0, TimeSpan.FromHours(2));
var fallFrom = new DateTimeOffset(2026, 10, 25, 1, 0, 0, TimeSpan.FromHours(2));
var fallTo = new DateTimeOffset(2026, 10, 25, 4, 0, 0, TimeSpan.FromHours(1));
Show("spring forward, daily 02:30", "30 2 * * *", springFrom, springTo.AddDays(1));
Show("fall back, daily 02:30", "30 2 * * *", fallFrom, fallTo);
Show("spring forward, every 30 min", "*/30 * * * *", springFrom, springTo);
Show("fall back, every 30 min", "*/30 * * * *", fallFrom, fallTo);

The results:

-- spring forward, daily 02:30: '30 2 * * *'
   Cronos: 03-29 03:00 +02:00, 03-30 02:30 +02:00
   Quartz: 03-29 03:00 +02:00, 03-30 02:30 +02:00
-- fall back, daily 02:30: '30 2 * * *'
   Cronos: 10-25 02:30 +02:00
   Quartz: 10-25 02:30 +02:00
-- spring forward, every 30 min: '*/30 * * * *'
   Cronos: 03-29 01:00 +01:00, 03-29 01:30 +01:00, 03-29 03:00 +02:00, 03-29 03:30 +02:00
   Quartz: 03-29 01:00 +01:00, 03-29 01:30 +01:00, 03-29 03:00 +02:00, 03-29 03:30 +02:00
-- fall back, every 30 min: '*/30 * * * *'
   Cronos: 10-25 01:00 +02:00, 10-25 01:30 +02:00, 10-25 02:00 +02:00, 10-25 02:30 +02:00, 10-25 02:00 +01:00, 10-25 02:30 +01:00, 10-25 03:00 +01:00, 10-25 03:30 +01:00
   Quartz: 10-25 01:00 +02:00, 10-25 01:30 +02:00, 10-25 02:00 +02:00, 10-25 02:30 +02:00, 10-25 02:00 +01:00, 10-25 02:30 +01:00, 10-25 03:00 +01:00, 10-25 03:30 +01:00

Both libraries give the same answer. In spring there's no such instant as 02:30; the daily job isn't lost, it moves to the first valid instant after the jump, 03:00. In autumn 02:30 happens twice (+02:00 and +01:00); the daily job runs only on the first one. The interval job, however, runs twice in the repeated hour: 02:00 and 02:30 exist at both +02:00 and +01:00. Cronos's README describes this as Vixie Cron behavior; the Quartz.NET 4.0 release notes also say interval cron expressions now fire during both occurrences of the repeated fall-back hour, where one used to be skipped.

This behavior is right for most work, but it has two consequences. The time between two runs of the daily report isn't 24 hours that day; it's 23 or 25. A report that computes "orders in the last 24 hours" skips an hour of data or counts it twice; compute the report with calendar boundaries such as "from yesterday 00:00 to today 00:00". Second, since an interval job runs twice in the repeated hour, it must be idempotent; that's the topic of the seventh post.

Your Own Cron Scheduler: Cronos and TimeProvider

If all you need is "run this job on this cron", you can write a small scheduler with Cronos and a BackgroundService, without installing Hangfire or Quartz. One important detail: read the clock from TimeProvider, not from DateTimeOffset.UtcNow. As we saw in the C# Async series post "C# Async and .NET 8: TimeProvider, ConfigureAwaitOptions, Parallel.ForAsync and CancelAsync", the overload of Task.Delay that takes a TimeProvider makes it possible to run against a fake clock in tests.

public abstract class CronJobService(
    string cron,
    TimeZoneInfo timeZone,
    TimeProvider time,
    ILogger logger) : BackgroundService
{
    private readonly CronExpression _expression = CronExpression.Parse(cron);

    protected abstract Task RunAsync(CancellationToken ct);

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            DateTimeOffset now = time.GetUtcNow();
            DateTimeOffset? next = _expression.GetNextOccurrence(now, timeZone);
            if (next is null)
                return; // the expression will never fire again

            // we pass TimeProvider to Task.Delay: a fake clock can be advanced in tests
            await Task.Delay(next.Value - now, time, stoppingToken);

            try
            {
                await RunAsync(stoppingToken);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                logger.LogError(ex, "Scheduled run for {Occurrence} failed", next);
            }
        }
    }
}

public sealed class NightlySalesReport(TimeProvider time, ILogger<NightlySalesReport> logger)
    : CronJobService("0 3 * * *", TimeZoneInfo.FindSystemTimeZoneById("Europe/Istanbul"), time, logger)
{
    public int Runs { get; private set; }
    public event Action? Completed;

    protected override Task RunAsync(CancellationToken ct)
    {
        Runs++;
        logger.LogInformation("Sales report generated at {Now}", time.GetUtcNow());
        Completed?.Invoke();
        return Task.CompletedTask;
    }
}

The loop recomputes the next run from the current time on every iteration; so if a long run misses the next slot, the scheduler doesn't try to run that slot afterwards, it waits for the one after. That's a simple version of one of Quartz's misfire policies; if missed runs must also be done, you need to store the time of the last successful run and compute the slots in between.

There's also the multiple-instance problem: this scheduler runs in every replica. The distributed lock from the eighth post will need to be added here.

Testing with FakeTimeProvider

To test a job that runs at 03:00 at night, you don't have to wait until 03:00. With FakeTimeProvider from the Microsoft.Extensions.TimeProvider.Testing package, you can move the clock forward by hand:

public class NightlySalesReportTests
{
    [Fact]
    public async Task Runs_once_at_03_00_Istanbul_and_again_next_night()
    {
        // 02:00 Istanbul = 23:00 UTC
        var time = new FakeTimeProvider(new DateTimeOffset(2026, 11, 8, 23, 0, 0, TimeSpan.Zero));
        var report = new NightlySalesReport(time, NullLogger<NightlySalesReport>.Instance);
        var ran = new SemaphoreSlim(0);
        report.Completed += () => ran.Release();

        await report.StartAsync(CancellationToken.None);

        time.Advance(TimeSpan.FromMinutes(59));          // 02:59
        Assert.False(await ran.WaitAsync(100));
        Assert.Equal(0, report.Runs);

        time.Advance(TimeSpan.FromMinutes(1));           // 03:00
        Assert.True(await ran.WaitAsync(1000));
        Assert.Equal(1, report.Runs);

        time.Advance(TimeSpan.FromHours(24));            // 03:00 the next night
        Assert.True(await ran.WaitAsync(1000));
        Assert.Equal(2, report.Runs);

        await report.StopAsync(CancellationToken.None);
    }
}
$ dotnet test
Passed!  - Failed:     0, Passed:     1, Skipped:     0, Total:     1, Duration: 115 ms - Bookstore.Scheduling.Tests.dll (net10.0)

The test finished in 115 ms, but 25 hours passed inside it. FakeTimeProvider.Advance fires the timers that are due; since Task.Delay(TimeSpan, TimeProvider, CancellationToken) uses one of those timers, the job thinks "it's 03:00" and runs. You can test the DST transition night the same way: set the FakeTimeProvider to the evening of 28 March 2026 and advance hour by hour with the Berlin time zone.

In Production

If you keep cron expressions in configuration, parse them at startup and refuse to start if one is invalid. Discovering a broken expression at its first run means the report doesn't run at all on the first night. Quartz 4's analyzer does this at compile time for constant expressions; for ones from configuration, use CronExpression.TryParse.

Log the next run time and show it on a dashboard. Having people eyeball "when will this job run next" is the cheapest way to catch time zone mistakes. Hangfire shows it on the recurring jobs page, Quartz 4 on its dashboard.

Don't give every job midnight and round hours. All reports running at 00:00 creates a load spike in the database at the same minute every night. Cronos's H character (schedule jitter inspired by Jenkins), or minutes spread out by hand, flattens that spike.

Common Pitfalls

Giving Quartz a Unix expression. 0 0 * * * is invalid in Quartz's default parser; it won't work without CronFormat.Unix. The weekday numbers differ too: 1 is Sunday in Quartz and Monday in Unix.

Not specifying a time zone. An expression that relies on local time silently starts running at another hour when the machine's or container's time zone changes.

Ignoring DST. Jobs scheduled between 02:00 and 03:00 behave unexpectedly two days a year. Avoid that window if you can; if you can't, make the job idempotent and build its calculations on calendar boundaries.

Mixing up "every 24 hours" and "every day at 03:00". The first is an interval, the second a wall-clock rule; they give different results on DST days and when the app restarts.

A Small Experiment

★☆☆ List every run time of the "last Friday of the month at 18:00" expression from the table throughout 2027 with Cronos's GetOccurrences method. How many fall before the 25th of the month?

★★☆ Set up NightlySalesReport with Europe/Berlin and 30 2 * * *, set the FakeTimeProvider to 28 March 2026 22:00 UTC and test what time the report runs by advancing hour by hour.

★★★ Add catching up on missed runs to CronJobService: store the time of the last successful run in a table and, at startup, run at most once depending on how many slots were missed. Which Quartz misfire instruction does this behavior resemble?

In the next post we move to the first big tool that solves scheduling and durability together: fire-and-forget, delayed, recurring and continuation jobs with Hangfire, how jobs are stored in SQL (Structured Query Language) Server, and what happens to old jobs when you change a method signature.