A production-grade cron job scheduler for Go — drop-in replacement for robfig/cron with runtime schedule updates, per-entry context, resilience middleware (retry, circuit breaker, rate limiting), and active maintenance.
robfig/cron — the most widely used Go cron library — has been unmaintained since 2020, accumulating 50+ open PRs and several critical panic bugs. go-cron is the actively maintained successor, fixing those issues and adding features demanded by real-world users like weaviate and ofelia:
| Area | robfig/cron | go-cron |
|---|---|---|
| TZ= parsing | Panics on malformed input | Fixed (#554, #555) |
| Chain decorators | Entry.Run() bypasses chains |
Properly invokes wrappers (#551) |
| DST spring-forward | Jobs silently skipped | Runs immediately (ISC behavior, #541) |
| DOM/DOW logic | OR (confusing) | AND (logical, consistent) |
| Runtime updates | Remove + re-add | UpdateSchedule, UpsertJob |
| Pause/Resume | None | PauseEntry, ResumeEntry |
| Triggered jobs | None | @triggered, TriggerEntry |
| Context support | None | Per-entry context, FuncJobWithContext |
| Resilience | None | Retry, circuit breaker, timeout, rate limiting |
| Observability | None | Hooks for metrics (Prometheus, etc.) |
| Go version | Stuck on 1.13 | Go 1.25+ with modern toolchain |
go get github.com/netresearch/go-cronimport cron "github.com/netresearch/go-cron"Note
Requires Go 1.25 or later.
go-cron is a drop-in replacement for robfig/cron v3 — just change the import path:
// Before
import "github.com/robfig/cron/v3"
// After
import cron "github.com/netresearch/go-cron"The API is 100% compatible with robfig/cron v3. However, go-cron includes intentional behavior changes that fix bugs and inconsistencies in the unmaintained upstream — see the comparison table above for a summary.
Warning
Behavior differences exist. While the API is compatible, some runtime behavior has changed (DOM/DOW matching, DST handling, chain execution). Review docs/MIGRATION.md before upgrading production systems.
package main
import (
"fmt"
"time"
cron "github.com/netresearch/go-cron"
)
func main() {
c := cron.New()
// Run every minute
c.AddFunc("* * * * *", func() {
fmt.Println("Every minute:", time.Now())
})
// Run at specific times
c.AddFunc("30 3-6,20-23 * * *", func() {
fmt.Println("In the range 3-6am, 8-11pm")
})
// With timezone
c.AddFunc("CRON_TZ=Asia/Tokyo 30 04 * * *", func() {
fmt.Println("4:30 AM Tokyo time")
})
c.Start()
// Keep running...
select {}
}Standard 5-field cron format (minute-first):
| Field | Required | Values | Special Characters |
|---|---|---|---|
| Minutes | Yes | 0-59 | * / , - |
| Hours | Yes | 0-23 | * / , - |
| Day of month | Yes | 1-31 | * / , - ? |
| Month | Yes | 1-12 or JAN-DEC | * / , - |
| Day of week | Yes | 0-6 or SUN-SAT | * / , - ? |
| Entry | Description | Equivalent |
|---|---|---|
@yearly |
Once a year, midnight, Jan 1 | 0 0 1 1 * |
@monthly |
Once a month, midnight, first day | 0 0 1 * * |
@weekly |
Once a week, midnight Sunday | 0 0 * * 0 |
@daily |
Once a day, midnight | 0 0 * * * |
@hourly |
Once an hour, beginning of hour | 0 * * * * |
@every <duration> |
Every interval | e.g., @every 1h30m |
@triggered |
Never auto-runs; manual only | aliases: @manual, @none |
For cyclic fields, ranges where start > end wrap around the boundary:
// Run from 10pm to 2am (spans midnight)
c.AddFunc("0 22-2 * * *", nightJob)
// Run Friday through Monday (spans weekend)
c.AddFunc("0 9 * * FRI-MON", weekendJob)
// Run November through February (spans year boundary)
c.AddFunc("0 0 1 NOV-FEB *", winterJob)Supported fields: seconds, minutes, hours, day-of-month, day-of-week, month. Non-existent days (e.g., Feb 31) are simply skipped.
Enable Quartz-compatible seconds field:
// Seconds field required
cron.New(cron.WithSeconds())
// Seconds field optional
cron.New(cron.WithParser(cron.NewParser(
cron.SecondOptional | cron.Minute | cron.Hour |
cron.Dom | cron.Month | cron.Dow | cron.Descriptor,
)))When both day-of-month and day-of-week are specified, both must match (AND logic). This is consistent with all other cron fields and enables useful patterns:
// Last Friday of month (days 25-31 AND Friday)
c.AddFunc("0 0 25-31 * FRI", lastFridayJob)
// First Monday of month (days 1-7 AND Monday)
c.AddFunc("0 0 1-7 * MON", firstMondayJob)
// Friday the 13th
c.AddFunc("0 0 13 * FRI", unluckyJob)Note
This differs from robfig/cron which uses OR logic. For migration compatibility,
use the DowOrDom option or see docs/MIGRATION.md.
Specify timezone per-schedule using CRON_TZ= prefix:
// Runs at 6am New York time
c.AddFunc("CRON_TZ=America/New_York 0 6 * * *", myFunc)
// Legacy TZ= prefix also supported
c.AddFunc("TZ=Europe/Berlin 0 9 * * *", myFunc)
// Quoted values are accepted (common shell habit)
c.AddFunc(`TZ="America/Chicago" 0 8 * * *`, myFunc)
c.AddFunc(`CRON_TZ='Asia/Tokyo' 30 4 * * *`, myFunc)Or set default timezone for all jobs:
nyc, _ := time.LoadLocation("America/New_York")
c := cron.New(cron.WithLocation(nyc))This library implements ISC cron-compatible DST behavior:
| Transition | Behavior |
|---|---|
| Spring Forward (hour skipped) | Jobs in skipped hour run immediately after transition |
| Fall Back (hour repeats) | Jobs run once, during first occurrence |
| Midnight DST (midnight doesn't exist) | Automatically normalized to valid time |
Tip
For DST-sensitive applications, schedule jobs outside typical transition hours (1-3 AM) or use UTC.
See docs/DST_HANDLING.md for comprehensive DST documentation including examples, testing strategies, and edge cases.
Assign names and tags to entries for lookup, update, and removal:
c.AddFunc("0 9 * * *", dailyReport,
cron.WithName("daily-report"),
cron.WithTags("reports", "daily"),
)
// Lookup by name (O(1))
entry := c.EntryByName("daily-report")
// Filter by tag
entries := c.EntriesByTag("reports")
// Remove by name
c.RemoveByName("daily-report")Update schedules and jobs without remove+re-add:
// Update schedule only (preserves job and context)
c.UpdateScheduleByName("daily-report", cron.Every(5*time.Minute))
// Update both schedule and job atomically (cancels old context)
c.UpdateEntryJobByName("daily-report", "30 10 * * *", newJob)
// Create-or-update in one call
id, err := c.UpsertJob("0 9 * * *", myJob, cron.WithName("my-job"))For graceful replacement of long-running jobs:
c.WaitForJobByName("my-job") // Block until current execution finishes
c.UpsertJob(newSpec, newJob, cron.WithName("my-job"))Temporarily suspend individual entries without removing them:
// Pause a running entry
c.PauseEntryByName("sync-job")
// Check if paused
if c.IsEntryPausedByName("sync-job") {
fmt.Println("Job is paused")
}
// Resume when ready
c.ResumeEntryByName("sync-job")
// Add entry in paused state (activate later)
c.AddFunc("@every 5m", syncData, cron.WithPaused(), cron.WithName("sync"))Paused entries remain registered with their schedule advancing, but execution is skipped. No catch-up flood occurs on resume.
Jobs that never fire automatically — only when you say so:
// Register a triggered job
c.AddFunc("@triggered", deploy, cron.WithName("deploy"))
c.Start()
// Trigger on demand (e.g., from an HTTP handler)
c.TriggerEntryByName("deploy")
// Works on regular entries too — "run now"
c.TriggerEntry(scheduledEntryID)Triggered entries benefit from the full middleware chain (retry, timeout, skip-if-running). Use @triggered, @manual, or @none — all are aliases.
Define job dependency graphs where downstream jobs trigger based on parent outcomes:
wf := cron.NewWorkflow("etl-pipeline")
wf.StepFunc("extract", "0 2 * * *", extractData)
wf.StepFunc("transform", "@triggered", transformData).
After("extract", cron.OnSuccess)
wf.StepFunc("load", "@triggered", loadData).
After("transform", cron.OnSuccess)
wf.StepFunc("cleanup", "@triggered", cleanup).
Final() // runs after all other steps complete
err := c.AddWorkflow(wf)Four trigger conditions control when dependent jobs fire:
| Condition | Fires when parent... |
|---|---|
OnSuccess |
completes without panicking |
OnFailure |
panics (use FuncErrorJob to convert errors) |
OnSkipped |
was skipped (condition not met) |
OnComplete |
resolves to any terminal state |
Workflow failure detection is panic-based: use FuncErrorJob (converts error → panic) or wrappers like RetryOnError/RetryWithBackoff for steps that return errors. The Recover wrapper is workflow-aware and correctly propagates failures.
For imperative wiring without the builder:
a, _ := c.AddFunc("@triggered", jobA, cron.WithName("a"))
b, _ := c.AddFunc("@triggered", jobB, cron.WithName("b"))
c.AddDependency(b, a, cron.OnSuccess) // b runs after a succeedsQuery workflow execution state:
status := c.WorkflowStatus(executionID) // by execution ID
active := c.ActiveWorkflows() // all in-progress executionsJobs implementing JobWithContext receive a per-entry context that is automatically canceled on removal or job replacement:
c.AddJob("@every 1m", cron.FuncJobWithContext(func(ctx context.Context) {
select {
case <-ctx.Done():
return // Entry removed or job replaced
case <-time.After(10 * time.Second):
// Work completed
}
}))All chain wrappers propagate context through the wrapper chain, so per-entry context reaches the innermost job.
Add cross-cutting behavior using chains:
// Apply to all jobs
c := cron.New(cron.WithChain(
cron.Recover(logger), // Recover panics
cron.SkipIfStillRunning(logger), // Skip if previous still running
))
// Apply to specific job
job := cron.NewChain(
cron.DelayIfStillRunning(logger), // Queue if previous still running
).Then(myJob)Available wrappers:
| Wrapper | Description |
|---|---|
Recover |
Catch panics, log, and continue |
SkipIfStillRunning |
Skip if previous run hasn't finished |
DelayIfStillRunning |
Queue until previous run finishes |
Timeout |
Abandon after duration (goroutine keeps running) |
TimeoutWithContext |
Cancel context after duration (cooperative cancellation) |
Jitter / JitterWithLogger |
Random delay to prevent thundering herd |
MaxConcurrent |
Limit total concurrent jobs (wait for slot) |
MaxConcurrentSkip |
Limit total concurrent jobs (skip when full) |
RetryWithBackoff |
Retry on panic with exponential backoff; WithRetryCallback for metrics |
RetryOnError |
Retry on error return (ErrorJob interface); WithRetryCallback for metrics |
CircuitBreaker |
Stop execution after consecutive failures; WithStateChangeCallback + CircuitBreakerWithHandle for monitoring |
Concurrency and resilience wrappers (Recover, SkipIfStillRunning, DelayIfStillRunning, Timeout, TimeoutWithContext, Jitter, JitterWithLogger) implement JobWithContext and propagate the incoming context to inner jobs. Retry and circuit breaker wrappers (RetryWithBackoff, RetryOnError, CircuitBreaker) do not currently forward context.
Validate cron expressions before scheduling:
// Package-level validation (no Cron instance needed)
if err := cron.ValidateSpec("0 9 * * MON-FRI"); err != nil {
log.Fatal(err)
}
// Instance-level validation (uses configured parser)
c := cron.New(cron.WithSeconds())
if err := c.ValidateSpec("0 30 * * * *"); err != nil {
log.Fatal(err)
}
// Detailed analysis
result := cron.AnalyzeSpec("0 9 * * MON-FRI")
fmt.Println("Next run:", result.NextRun)
fmt.Println("Fields:", result.Fields)Monitor cron operations with hooks:
c := cron.New(cron.WithObservability(cron.ObservabilityHooks{
OnJobStart: func(id cron.EntryID, name string, scheduled time.Time) {
jobsStarted.WithLabelValues(name).Inc()
},
OnJobComplete: func(id cron.EntryID, name string, dur time.Duration, recovered any) {
jobDuration.WithLabelValues(name).Observe(dur.Seconds())
},
}))Monitor circuit breaker state and retry attempts:
wrapper, handle := cron.CircuitBreakerWithHandle(logger, 5, 5*time.Minute,
cron.WithStateChangeCallback(func(e cron.CircuitBreakerEvent) {
circuitState.WithLabelValues(e.NewState.String()).Set(1)
}),
)
// handle.State(), handle.Failures(), handle.CooldownEnds() for health checks
cron.RetryWithBackoff(logger, 3, time.Second, time.Minute, 2.0,
cron.WithRetryCallback(func(a cron.RetryAttempt) {
retryCounter.WithLabelValues(fmt.Sprint(a.Attempt)).Inc()
}),
)Query job status at runtime:
if c.IsJobRunningByName("my-job") {
fmt.Println("Job is currently running")
}Deterministic testing without real time waits:
fakeClock := cron.NewFakeClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
c := cron.New(cron.WithClock(fakeClock))
c.AddFunc("0 * * * *", myJob)
c.Start()
fakeClock.BlockUntil(1) // Wait for scheduler to register timer
fakeClock.Advance(time.Hour) // Trigger the job deterministicallyCompatible with go-logr/logr and log/slog:
// Printf-style
c := cron.New(cron.WithLogger(
cron.VerbosePrintfLogger(log.New(os.Stdout, "cron: ", log.LstdFlags)),
))
// slog
c := cron.New(cron.WithLogger(cron.NewSlogLogger(slog.Default())))// Block until all jobs finish
c.StopAndWait()
// With timeout
if !c.StopWithTimeout(30 * time.Second) {
log.Println("Warning: some jobs did not complete within 30s")
}Handle jobs that were missed while the scheduler was not running (e.g., application restart):
// Load last run time from your database
lastRun := loadFromDatabase("daily-report")
c.AddFunc("0 9 * * *", dailyReport,
cron.WithPrev(lastRun), // When it last ran
cron.WithMissedPolicy(cron.MissedRunOnce), // Run once if missed
cron.WithMissedGracePeriod(2*time.Hour), // Only if within 2 hours
)Policies:
MissedSkip(default) — No catch-up, wait for next scheduled timeMissedRunOnce— Run once immediately for the most recent missed executionMissedRunAll— Run for every missed execution (capped at 100 for safety)
Important
The scheduler does NOT persist state. You must provide the last run time via WithPrev()
and store it yourself (database, file, etc.). See docs/PERSISTENCE_GUIDE.md
for complete integration patterns.
Query schedules without running them — useful for calendar previews, audit logs, and debugging:
schedule, _ := cron.ParseStandard("0 9 * * MON-FRI")
now := time.Now()
// Upcoming executions
upcoming := cron.NextN(schedule, now, 5)
// Past executions (requires ScheduleWithPrev)
recent := cron.PrevN(schedule, now, 5)
// Executions in a time range
start, end := now, now.AddDate(0, 1, 0)
times := cron.Between(schedule, start, end) // all in range
capped := cron.BetweenWithLimit(schedule, start, end, 100) // at most 100
// Count executions
total := cron.Count(schedule, start, end)
// Check if a time matches the schedule
if cron.Matches(schedule, now) {
fmt.Println("Now is a scheduled time!")
}PrevN returns times in reverse chronological order (most recent first). It returns nil when the schedule doesn't implement ScheduleWithPrev. All built-in schedules support it.
- API Reference — Complete type and method documentation
- Cookbook — Recipes for common patterns
- Migration Guide — Migrating from robfig/cron
- DST Handling — Daylight Saving Time behavior
- Persistence Guide — Storing and restoring job state
- Changelog — Release history
- pkg.go.dev — Go reference documentation
Contributions are welcome! Please read CONTRIBUTING.md before submitting PRs.
For security issues, please see SECURITY.md.
MIT License — see LICENSE for details.
go-cron is maintained by Netresearch. Originally based on the cron library created by Rob Figueiredo.