A fast, well-tested cron expression parser and job scheduler for Go.
This is a modernized fork of the abandoned
robfig/cron/v3. It ships an idiomatic
Go 1.26+ API with context.Context, log/slog, and a testable Clock
interface.
go get github.com/hyp3rd/cron/v4@latestpackage main
import (
"context"
"fmt"
"os"
"os/signal"
"github.com/hyp3rd/cron/v4"
)
func main() {
c := cron.New()
c.AddFunc("@every 5s", func(ctx context.Context) error {
fmt.Println("tick")
return nil
})
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
c.Start(context.Background())
<-ctx.Done()
c.Shutdown(context.Background())
}- Standard 5-field cron expressions (minute, hour, dom, month, dow) plus
optional seconds via
WithSeconds(). - Context-aware lifecycle —
Start,Run,Stop,Shutdown, and everyJobparticipate in cancellation and deadlines. log/sloglogging — structured, leveled logging out of the box. Default level isslog.LevelWarnto keep the scheduler quiet.Clockinterface — inject a fake clock viaWithClockfor deterministic, zero-time.Sleeptests.- Job wrappers —
Recover,SkipIfStillRunning,DelayIfStillRunning,Timeout,MaxConcurrent,RetryOnError, and customJobWrapperchains. - Defensive configuration — malformed
TZ=/CRON_TZ=prefixes and invalid@everyintervals return parse errors; nilWith*options keep defaults. - Named entries —
AddNamedFunc/AddNamedJobattach human-readable labels for logging and observability. - Event hooks —
WithEventHooksforOnJobStart/OnJobCompletecallbacks;WithOnErrorfor error-only alerting. - Schedule inspection —
NextNpreviews future fire times;SpecSchedule.String()round-trips a parsed schedule back to a cron expression. - Thread-safe — add, remove, and inspect entries while the scheduler is running.
| Field | Allowed values | Special characters |
|---|---|---|
| Minutes | 0-59 | * / , - |
| Hours | 0-23 | * / , - |
| Day of month | 1-31 | * / , - ? |
| Month | 1-12 or JAN-DEC | * / , - |
| Day of week | 0-6 or SUN-SAT | * / , - ? |
| Entry | Equivalent |
|---|---|
@yearly |
0 0 1 1 * |
@monthly |
0 0 1 * * |
@weekly |
0 0 * * 0 |
@daily |
0 0 * * * |
@hourly |
0 * * * * |
@every 1h30m
@every accepts positive time.ParseDuration values. Durations smaller than a
second still round up to 1 second; @every 0s and negative durations are
rejected as configuration errors.
cron.New(cron.WithLocation(time.UTC))
// or per-schedule:
c.AddFunc("CRON_TZ=Asia/Tokyo 0 6 * * ?", myJob)Malformed timezone prefixes such as CRON_TZ= or CRON_TZ=UTC without a
schedule body return parse errors instead of panicking.
Use Start(ctx) for a background scheduler and Run(ctx) for a blocking one.
The context passed to Start or Run is also the parent context for every job.
Stop(ctx)cancels the scheduler and cancels contexts already handed to running jobs before waiting for them to return.Shutdown(ctx)stops future scheduling and waits for running jobs to finish without cancelling their contexts.- If the
Start/Runcontext is cancelled directly, both the scheduler and job contexts are cancelled.
Panic recovery is available but not enabled by default. Install Recover
explicitly if you want panics turned into logged ErrPanic errors:
c := cron.New(cron.WithChain(
cron.Recover(logger),
cron.SkipIfStillRunning(logger),
))Or per-job:
wrapped := cron.NewChain(cron.Recover(logger)).Then(myJob)c := cron.New(cron.WithChain(
cron.Timeout(30 * time.Second), // cancel after 30s
cron.MaxConcurrent(3, logger), // allow up to 3 in parallel
cron.RetryOnError(2, 5 * time.Second), // retry twice with 5s backoff
cron.Recover(logger),
))c.AddNamedFunc("daily-report", "0 9 * * *", generateReport)
for _, e := range c.Entries() {
fmt.Println(e.ID, e.Name, e.Next)
}c := cron.New(cron.WithEventHooks(cron.EventHooks{
OnJobStart: func(id cron.EntryID, name string) {
span := tracer.Start(name) // start a trace span
},
OnJobComplete: func(id cron.EntryID, name string, elapsed time.Duration, err error) {
metrics.Observe(name, elapsed, err) // record metrics
},
}))For error-only callbacks (alerting, retries):
c := cron.New(cron.WithOnError(func(id cron.EntryID, name string, err error) {
alerting.Notify(name, err)
}))// Preview the next 5 fire times
sched, _ := cron.ParseStandard("0 */6 * * *")
for _, t := range cron.NextN(sched, time.Now(), 5) {
fmt.Println(t)
}
// Round-trip a parsed schedule back to a string
fmt.Println(sched) // "0 0,6,12,18 * * * *"The Clock interface lets you drive the scheduler deterministically:
c := cron.New(cron.WithClock(fakeClock))See clock.go for the interface definition.
Passing nil to WithLocation, WithParser, WithLogger, or WithClock
keeps the package default instead of leaving the scheduler in an invalid state.
See MIGRATION.md for a step-by-step upgrade guide.
See CHANGELOG.md.
MIT - see LICENSE.