Croner 1.0.0-preview.2

This is a prerelease version of Croner.
dotnet add package Croner --version 1.0.0-preview.2
                    
NuGet\Install-Package Croner -Version 1.0.0-preview.2
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Croner" Version="1.0.0-preview.2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Croner" Version="1.0.0-preview.2" />
                    
Directory.Packages.props
<PackageReference Include="Croner" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Croner --version 1.0.0-preview.2
                    
#r "nuget: Croner, 1.0.0-preview.2"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Croner@1.0.0-preview.2
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Croner&version=1.0.0-preview.2&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Croner&version=1.0.0-preview.2&prerelease
                    
Install as a Cake Tool

Croner for .NET

Croner is available for JavaScript/TypeScript · Rust · Python · .NET · C++

NuGet Build

Cron expressions and scheduled jobs for .NET, with daylight saving time handled correctly.

  • Parse OCPS-compliant patterns: 5, 6 or 7 fields (seconds and years), L, W, LW, #, ?, +, nicknames like @daily, and month and weekday names. Quartz-style weekday numbering on request.
  • Search next and previous occurrences in any TimeZoneInfo, or iterate them lazily. Times skipped or repeated by a DST change are handled by well-defined rules, in both directions.
  • Describe patterns in plain English or Swedish, or your own language: "At 07:30, on Monday, Tuesday, Wednesday, Thursday, and Friday."
  • Schedule sync or async callbacks with pause/resume/stop, max runs, minimum interval, overrun protection, error handling, start/stop dates and day offsets.
  • Test schedules deterministically: jobs take their clock and timers from a TimeProvider.
  • No dependencies. Targets .NET 8 and .NET 10. Trimming- and AOT-compatible. Cron is immutable and thread-safe.

Installation

dotnet add package Croner

Quick examples

using Croner;

// Evaluate a pattern
var cron = Cron.Parse("0 0 * * FRI");
DateTimeOffset next = cron.GetNextOccurrence(DateTimeOffset.UtcNow, TimeZoneInfo.Local);
DateTimeOffset previous = cron.GetPreviousOccurrence(DateTimeOffset.UtcNow, TimeZoneInfo.Local);
Console.WriteLine(cron.Describe()); // "At 00:00, on Friday."

// Run a job every five seconds
using var job = new CronJob("*/5 * * * * *", _ => Console.WriteLine("This will run every fifth second"));

// Run a job once, at a given time
using var once = new CronJob(DateTimeOffset.UtcNow.AddMinutes(1), _ => Console.WriteLine("One minute later"));

// Async callbacks with cancellation, in a time zone, with options
await using var report = new CronJob("0 30 7 * * MON-FRI", async (job, cancellationToken) =>
{
    await SendReportAsync(cancellationToken);
}, new CronJobOptions
{
    TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Stockholm"),
    PreventOverlap = true,                     // skip a run while the previous one is still executing
    OnError = (e, job) => Console.WriteLine(e), // handle exceptions from the callback
});

Evaluating patterns: Cron

var cron = Cron.Parse("0 12 * * MON-FRI");     // throws CronException when invalid

if (Cron.TryParse(userInput, out Cron? parsed))  // returns false for null or invalid input
{
    Console.WriteLine(parsed.Describe());
}

// With parser options
var quartz = Cron.Parse("0 0 12 ? * 2-6", new CronParserOptions
{
    Seconds = SecondsMode.Required,   // Optional (default), Required, Disallowed
    Year = YearMode.Optional,         // Optional (default), Required, Disallowed
    QuartzWeekdays = true,            // 1 = Sunday ... 7 = Saturday
    RequireBothDayFields = false,     // AND instead of OR for day-of-month and day-of-week
    LenientSteps = false,             // accept non-standard "0/10" and "/10"
});

Cron implements IParsable<Cron>, so it works with generic parsing code and configuration binders that use it.

Next and previous occurrences

Each search takes a start time and, optionally, whether that time itself may be returned (inclusive, default false). Results are whole seconds.

Overload Evaluates the pattern in
GetNextOccurrence(DateTimeOffset from, TimeZoneInfo zone, bool inclusive = false) zone, with DST handling; the result carries the zone's offset
GetNextOccurrence(DateTimeOffset from, bool inclusive = false) the fixed UTC offset of from
GetNextOccurrence(DateTime from, bool inclusive = false) UTC for DateTimeKind.Utc, TimeZoneInfo.Local for Local, a plain wall clock for Unspecified; the result has the same kind

GetPreviousOccurrence has the same overloads. Both throw CronException with Kind == CronErrorKind.SearchLimitExceeded when no occurrence exists (for example 0 0 30 2 *). TryGetNextOccurrence and TryGetPreviousOccurrence return false instead; pass null as the zone to use the fixed offset of from.

var tz = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
var cron = Cron.Parse("0 9 * * *");

DateTimeOffset next = cron.GetNextOccurrence(DateTimeOffset.UtcNow, tz);   // 09:00 New York time, offset -04:00 or -05:00

if (cron.TryGetPreviousOccurrence(DateTimeOffset.UtcNow, tz, out DateTimeOffset previous))
{
    Console.WriteLine(previous);
}

Enumerating occurrences

// The next five, lazily
foreach (DateTimeOffset run in cron.GetNextOccurrences(DateTimeOffset.UtcNow, tz).Take(5))
{
    Console.WriteLine(run);
}

// The latest three, most recent first
var latest = cron.GetPreviousOccurrences(DateTimeOffset.UtcNow, tz).Take(3).ToList();

// All occurrences in a range: from inclusive, to exclusive
var thisWeek = cron.GetOccurrences(startOfWeek, startOfWeek.AddDays(7), tz).ToList();

GetNextOccurrences and GetPreviousOccurrences also take inclusive, and have DateTime overloads. The sequences end when the search limit is reached, so take what you need.

Matching

cron.IsMatch(DateTimeOffset.Now);                        // wall clock of the value itself
cron.IsMatch(DateTimeOffset.UtcNow, tz);                 // wall clock in tz
cron.IsMatch(new DateTime(2025, 1, 1, 9, 0, 0));          // wall clock fields

Describing

Cron.Parse("0 12 * * MON-FRI").Describe();
// "At 12:00, on Monday, Tuesday, Wednesday, Thursday, and Friday."

Cron.Parse("0 0 1 */2 *").Describe(Croner.Describing.Swedish.Instance);
// "Klockan 00:00, på dag 1, i var 2:e månad."

Implement Croner.Describing.ICronLanguage for other languages, or to change details such as using a 12-hour clock (TimeOfDay).

Inspecting a pattern

var p = Cron.Parse("0 9-11 * * 1-5").Pattern;
p.Expression;                 // "0 9-11 * * 1-5"
p.Hours.GetValues();          // [9, 10, 11]
p.DaysOfWeek.GetValues();     // [1, 2, 3, 4, 5]  (0 = Sunday, whatever numbering the pattern used)
p.DaysOfWeek.HasModifiers;    // false; true when the field uses L, W or #
p.IsDayOfMonthWildcard;       // true
cron.JobType;                 // FixedTime or IntervalWildcard (decides DST behaviour)

Cron and CronPattern compare by their parsed fields, not by spelling: Cron.Parse("0 0 * * MON-FRI").Equals(Cron.Parse("0 0 * * 1-5")) is true.

Scheduling jobs: CronJob

var job = new CronJob("*/10 * * * * *", j => Console.WriteLine($"Run at {j.CurrentRun}"));

job.Pause();
job.Resume();
await job.TriggerAsync();   // run now, outside the schedule

job.Stop();                 // stop the timer and cancel running callbacks
await job.StopAsync();      // ... and wait until they have finished

Callbacks can be Action<CronJob>, Func<CronJob, Task> or Func<CronJob, CancellationToken, Task>. The token is cancelled when the job is stopped or disposed; DisposeAsync waits for running callbacks like StopAsync.

A CronJob created without a callback only evaluates its schedule; Start(callback) starts it later.

Member Description
GetNextRun(from?) The next run after from (default now), or null
GetNextRuns(from?) Lazily enumerates future runs, respecting MaxRuns, StopAt and the other options
GetPreviousRuns(before?) Lazily enumerates past runs, most recent first
GetTimeToNextRun(from?) Time until the next run
IsMatch(time) Whether a time matches the schedule
IsScheduled / IsPaused / IsExecuting / IsStopped Waiting for a future run / paused / a callback is executing / stopped for good
CurrentRun / PreviousRun Start of the current (or latest) run / latest completed run
RemainingRuns Remaining runs with MaxRuns, otherwise null
Cron / RunOnceAt The parsed schedule / the time of a one-off job
Name / Context From the options

Options

Option Default Description
Name null A name, e.g. for logging; included in ToString()
Parser CronParserOptions.Default How the pattern is parsed
TimeZone local time zone Time zone the pattern is evaluated in
UtcOffset null Use a fixed UTC offset instead of a time zone
StartAt / StopAt null Run only from StartAt (inclusive) until StopAt (exclusive)
MaxRuns unlimited Maximum number of scheduled runs
MinimumInterval 0 Minimum time between runs (whole seconds)
DayOffset 0 Run this many days after (or, negative, before) each match
StartPaused false Create the job paused
PreventOverlap false Skip a run while the previous one is still executing
OnSkipped null Called when PreventOverlap skips a run (implies PreventOverlap)
OnError null Called with exceptions from the callback
RunIfPast false Run a one-off job whose time has passed right away
Context null Any value, available as job.Context
TimeProvider TimeProvider.System The clock and timers to use, e.g. a fake one in tests

Without OnError, an exception thrown by a scheduled run is rethrown on the thread pool, which ends the process like any unhandled exception in a timer callback, and an exception from TriggerAsync() goes to the returned task. An OperationCanceledException thrown because the job was stopped is not an error.

A run that is due while the process is suspended, or while the clock jumps forward, runs once when the timer fires; missed runs are not repeated.

In a hosted application

A CronJob runs on its own timer, so an IHostedService only has to start and stop it:

builder.Services.AddHostedService<ReportService>();

sealed class ReportService : IHostedService, IAsyncDisposable
{
    private CronJob? _job;

    public Task StartAsync(CancellationToken cancellationToken)
    {
        _job = new CronJob("0 30 7 * * MON-FRI", (_, ct) => SendReportAsync(ct), new CronJobOptions
        {
            TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Stockholm"),
            PreventOverlap = true,
            OnError = (e, _) => Console.WriteLine(e),
        });
        return Task.CompletedTask;
    }

    // Cancels a running report and waits for it, as long as the host allows.
    public Task StopAsync(CancellationToken cancellationToken) =>
        _job?.StopAsync(cancellationToken) ?? Task.CompletedTask;

    public ValueTask DisposeAsync() => _job?.DisposeAsync() ?? ValueTask.CompletedTask;
}

Testing

Pass a TimeProvider to control time, for example FakeTimeProvider from Microsoft.Extensions.TimeProvider.Testing:

var time = new FakeTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
int runs = 0;
using var job = new CronJob("0 * * * *", _ => runs++, new CronJobOptions
{
    TimeProvider = time,
    TimeZone = TimeZoneInfo.Utc,
});

for (int i = 0; i < 3; i++)
{
    time.Advance(TimeSpan.FromHours(1));
}
// runs == 3

Advance the clock one run at a time: a single Advance(TimeSpan.FromHours(3)) is a clock jump, and runs once.

Pattern

┌──────────────── (optional) second (0 - 59)
│ ┌────────────── minute (0 - 59)
│ │ ┌──────────── hour (0 - 23)
│ │ │ ┌────────── day of month (1 - 31)
│ │ │ │ ┌──────── month (1 - 12, JAN-DEC)
│ │ │ │ │ ┌────── day of week (0 - 7, SUN-SAT) (0 to 6 are Sunday to Saturday; 7 is Sunday, the same as 0)
│ │ │ │ │ │ ┌──── (optional) year (1 - 5000)
│ │ │ │ │ │ │
* * * * * * *
Field Required Allowed values Allowed special characters Remarks
Seconds Optional 0-59 * , - /
Minutes Yes 0-59 * , - /
Hours Yes 0-23 * , - /
Day of month Yes 1-31 * , - / ? L W L = last day, 15W = weekday nearest the 15th, LW = last weekday
Month Yes 1-12 or JAN-DEC * , - /
Day of week Yes 0-7 or SUN-SAT * , - / ? L # + 5L = last Friday, 1#2 = second Monday, + prefix = AND with day of month (L needs a weekday here)
Year Optional 1-5000 * , - /
  • Patterns are case-insensitive. Leading zeros and extra whitespace are fine. Month names are only accepted in the month field, and weekday names in the day-of-week field.
  • With 5 fields, seconds default to 0; without a year, any year matches.
  • Steps need a starting point, as in Vixie cron and OCPS: */10 and 5-59/10 work; 0/10 and /10 need LenientSteps.
  • When both day of month and day of week are set, a day matching either runs (OR, as in Vixie cron). Use RequireBothDayFields = true, or prefix the day of week with + (0 0 1 * +MON), to require both.
Nickname Equivalent
@yearly, @annually 0 0 1 1 *
@monthly 0 0 1 * *
@weekly 0 0 * * 0
@daily, @midnight 0 0 * * *
@hourly 0 * * * *

Daylight saving time

Patterns describe wall clock time, but a time zone's clock sometimes skips an hour (spring forward) or repeats one (fall back). Searches walk the wall clock of the requested time zone and resolve those hours by the kind of pattern (Cron.JobType):

  • Fixed-time jobs (single second, minute and hour, e.g. 0 30 2 * * *):
    • when the clock jumps forward over the run time, the job runs at the first instant after the jump;
    • when the clock goes back and the run time happens twice, the job runs once, at the first occurrence.
  • Interval and wildcard jobs (everything else, e.g. */5 * * * *):
    • times skipped by a jump forward are skipped;
    • times that happen twice run twice, in real order.

Searches never return a time they have already passed, also when starting inside a repeated hour, in either direction.

Development

dotnet build
dotnet test
dotnet pack src/Croner -c Release

Releases are published to NuGet by .github/workflows/build.yml through trusted publishing: push a tag such as v1.0.0 and the package is built, tested and pushed with that version.

License

MIT

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.
  • net8.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0-preview.2 0 11/8/2026
1.0.0-preview.1 39 11/7/2026