Go Cron Scheduling — A Complete Guide
Go has two established scheduling libraries — robfig/cron (the original, battle-tested, though frozen since 2020) and go-co-op/gocron (modern, fluent API, actively maintained). Both run in-process; for distributed scheduling across many Go processes you either roll your own leader election or hand the job off to Kubernetes CronJob. This guide covers both libraries plus the patterns that matter in production.
Updated
The two main libraries
Go has two production-grade scheduling libraries:
robfig/cron — the original, simple, battle-tested. Used in countless Go projects. Standard Unix 5-field cron syntax (with optional seconds prefix). Minimal API, no frills.
go-co-op/gocron — newer (2020+), fluent API, more features (named jobs, tags, hooks, distributed locking). Modeled loosely on Quartz.
Both are MIT-licensed. gocron is actively maintained; robfig/cron is stable but effectively frozen since v3.0.1 (2020) — fine for its small, settled feature set, just don’t expect new releases. The decision is mostly stylistic:
- robfig if you want minimal dependencies and prefer raw cron strings
- gocron if you prefer a fluent API and want named jobs / built-in distributed coordination
Avoid older alternatives (jasonlvhit/gocron — different package, predecessor to go-co-op; cronexpr — parsing only). Stick to one of these two for any new code.
robfig/cron
go get github.com/robfig/cron/v3
Basic usage:
import "github.com/robfig/cron/v3"
c := cron.New()
c.AddFunc("*/5 * * * *", func() {
log.Println("running every 5 minutes")
})
c.AddFunc("0 9 * * MON-FRI", func() {
sendDigest()
})
c.Start()
defer c.Stop()
The default parser uses standard 5-field Unix cron — see the Unix dialect reference. For seconds-precision, use the optional seconds parser:
c := cron.New(cron.WithSeconds())
c.AddFunc("*/30 * * * * *", heartbeat) // every 30 seconds
Important: robfig/cron v1 used 6-field-with-seconds by default. v3 is 5-field by default. Code upgraded from v1 may silently break — what was a minute field in v1 becomes a seconds field interpretation error in v3. The fix: pass cron.WithSeconds() or remove the leading 0 from your expressions.
Add jobs that return errors (with a logger that handles them):
c := cron.New(cron.WithChain(
cron.SkipIfStillRunning(cron.DefaultLogger),
cron.Recover(cron.DefaultLogger),
))
c.AddFunc("*/5 * * * *", func() {
if err := pollExternalAPI(); err != nil {
log.Printf("poll failed: %v", err)
}
})
The chained middleware:
SkipIfStillRunning— if the previous fire is still going, skip this one (prevents overlap)Recover— recover from panics in the job function
You can also use cron.DelayIfStillRunning to queue overlapping fires instead of skipping them.
go-co-op/gocron
go get github.com/go-co-op/gocron/v2
Fluent API:
import "github.com/go-co-op/gocron/v2"
s, _ := gocron.NewScheduler()
defer s.Shutdown()
s.NewJob(
gocron.CronJob("*/5 * * * *", false), // false = no seconds
gocron.NewTask(refreshCache),
gocron.WithName("cache-refresh"),
)
s.NewJob(
gocron.DurationJob(10*time.Second),
gocron.NewTask(heartbeat),
)
s.NewJob(
gocron.DailyJob(1, gocron.NewAtTimes(gocron.NewAtTime(9, 0, 0))),
gocron.NewTask(sendDigest),
)
s.Start()
gocron’s job builders cover the common cases without needing to write cron strings:
DurationJob(10*time.Second)— every NDailyJob(interval, NewAtTimes(...))— every N days at specific timesWeeklyJob(...),MonthlyJob(...)— calendar-alignedCronJob("0 9 * * MON-FRI", false)— standard cron
It also has features robfig doesn’t:
- Named jobs and tags — query/manage by name or tag
- Job hooks —
BeforeJobRuns,AfterJobRuns,AfterJobRunsWithError - Singleton mode —
WithSingletonMode(gocron.LimitModeReschedule)prevents overlap, similar to robfig’sSkipIfStillRunning - Distributed locker — pluggable interface for Redis/etcd-backed distributed locks
For new projects, gocron’s API is more ergonomic. For minimal dependencies and “I just need cron,” robfig is fine.
Timezones
Both libraries default to the Go process’s local timezone, which on cloud containers is usually UTC but is a configuration choice you have to remember.
robfig/cron:
loc, _ := time.LoadLocation("America/New_York")
c := cron.New(cron.WithLocation(loc))
c.AddFunc("0 9 * * MON-FRI", sendDigest) // 9 AM New York time
gocron:
loc, _ := time.LoadLocation("America/New_York")
s, _ := gocron.NewScheduler(gocron.WithLocation(loc))
To use UTC explicitly:
c := cron.New(cron.WithLocation(time.UTC))
For Go binaries deployed via Docker, the timezone database might not be installed by default. Add it to your Dockerfile:
RUN apk add --no-cache tzdata
Without tzdata, time.LoadLocation("America/New_York") returns an error and falls back to UTC, which is a silent timezone shift you’ll wish you’d caught earlier.
Graceful shutdown
When the process gets SIGTERM (Docker stopping the container, Kubernetes rolling deployment), in-flight scheduled jobs should be given a chance to finish.
robfig:
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Listen for shutdown signal
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigs
log.Println("shutdown signal received")
cancel()
}()
c := cron.New()
// ... add jobs ...
c.Start()
<-ctx.Done()
// Stop returns a context that completes when in-flight jobs finish
shutdownCtx := c.Stop()
select {
case <-shutdownCtx.Done():
log.Println("scheduled jobs drained")
case <-time.After(30 * time.Second):
log.Println("shutdown timeout, exiting anyway")
}
gocron:
s, _ := gocron.NewScheduler()
// ... add jobs ...
s.Start()
// Wait for signal, then:
if err := s.Shutdown(); err != nil {
log.Printf("shutdown error: %v", err)
}
gocron’s Shutdown() waits for active jobs by default — pass gocron.WithStopTimeout(timeout) when constructing the scheduler to bound the wait.
For Kubernetes deployments, set the pod’s terminationGracePeriodSeconds to slightly more than your scheduler’s stop timeout so the pod has time to shut down cleanly before being force-killed.
Logging
Both libraries support pluggable loggers. Wire them to your structured logger (slog, zap, zerolog) instead of using their defaults.
robfig with slog:
type SlogCronLogger struct {
logger *slog.Logger
}
func (l *SlogCronLogger) Info(msg string, keysAndValues ...interface{}) {
l.logger.Info(msg, keysAndValues...)
}
func (l *SlogCronLogger) Error(err error, msg string, keysAndValues ...interface{}) {
args := append([]any{"error", err}, keysAndValues...)
l.logger.Error(msg, args...)
}
c := cron.New(
cron.WithLogger(&SlogCronLogger{logger: slog.Default()}),
cron.WithChain(cron.Recover(&SlogCronLogger{logger: slog.Default()})),
)
gocron with hooks:
s.NewJob(
gocron.CronJob("*/5 * * * *", false),
gocron.NewTask(refreshCache),
gocron.WithEventListeners(
gocron.BeforeJobRuns(func(jobID uuid.UUID, jobName string) {
slog.Info("job starting", "id", jobID, "name", jobName)
}),
gocron.AfterJobRunsWithError(func(jobID uuid.UUID, jobName string, err error) {
slog.Error("job failed", "id", jobID, "name", jobName, "error", err)
}),
),
)
Log each scheduled fire with the cron expression that triggered it, the job name (if you have one), start time, and duration. Errors should include the stack trace.
Distributed scheduling
Both libraries are in-process — running them in N Go instances means every job fires N times. Options for “exactly once” semantics:
1. Leader election. Run the scheduler only on the leader.
import "go.etcd.io/etcd/client/v3/concurrency"
session, _ := concurrency.NewSession(etcdClient)
election := concurrency.NewElection(session, "/cron/leader")
ctx := context.Background()
go func() {
for {
if err := election.Campaign(ctx, hostname); err == nil {
// We're the leader — start the scheduler
c.Start()
<-ctx.Done()
return
}
}
}()
Similar patterns work with Consul, ZooKeeper, or PostgreSQL advisory locks.
2. Per-job distributed lock. Each instance runs the scheduler, but each job acquires a Redis lock before doing work:
func withLock(redisClient *redis.Client, key string, ttl time.Duration, fn func()) func() {
return func() {
ok, _ := redisClient.SetNX(ctx, key, "1", ttl).Result()
if !ok {
return // another instance is running this job
}
defer redisClient.Del(ctx, key)
fn()
}
}
c.AddFunc("*/5 * * * *", withLock(redis, "job:cache-refresh", 5*time.Minute, refreshCache))
gocron has built-in support for this via WithDistributedLocker(locker).
3. Move scheduling out of Go. Use Kubernetes CronJob or AWS EventBridge Scheduler to invoke a Go HTTP endpoint on a schedule. This eliminates the multi-instance coordination problem entirely — the scheduler runs in one place, your Go code stays stateless and horizontal.
For most production Go services that need distributed scheduling, option 3 is the cleanest path. Options 1 and 2 work but add operational complexity (etcd, Redis, lock cleanup).
Choosing the right one
- Single Go process, simple needs? →
robfig/cron/v3 - Want a more fluent API + named jobs + hooks? →
go-co-op/gocron - Need distributed locking built in? →
gocronwithWithDistributedLocker - Sub-minute precision? →
gocronDurationJoborrobfigwithWithSeconds() - Multi-instance Go deployment? → Pick one of: per-job Redis lock, leader election, or move scheduling to Kubernetes CronJob
- Need SLA-grade reliability? → Kubernetes CronJob or AWS EventBridge Scheduler invoking a Go HTTP endpoint
For most production Go services, in-process scheduling with one of these libraries plus a Redis lock for “exactly once” is sufficient. For SLA-bound work, decouple the scheduler from your Go binary entirely.
Frequently asked questions
Which Go cron library should I use?
What cron syntax does robfig/cron use?
How do I set a timezone for my Go cron jobs?
How do I handle graceful shutdown of a Go scheduler?
How do I run a Go cron job exactly once across multiple instances?
Related
Every 5 Minutes
`*/5 * * * *` — common Go scheduled-job interval.
PatternDaily at Midnight
`0 0 * * *` — nightly Go scheduled jobs.
ToolCron Parser
Verify cron expressions before adding them to your Go scheduler.
GuideNode.js Cron Jobs
node-cron, agenda, bullmq — the Node equivalents.
GuideKubernetes CronJob
Run scheduled Go work as a K8s CronJob for SLA-grade reliability.