cronuru
Guide

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 N
  • DailyJob(interval, NewAtTimes(...)) — every N days at specific times
  • WeeklyJob(...), MonthlyJob(...) — calendar-aligned
  • CronJob("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 hooksBeforeJobRuns, AfterJobRuns, AfterJobRunsWithError
  • Singleton modeWithSingletonMode(gocron.LimitModeReschedule) prevents overlap, similar to robfig’s SkipIfStillRunning
  • 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?gocron with WithDistributedLocker
  • Sub-minute precision?gocron DurationJob or robfig with WithSeconds()
  • 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?
For most cases, either `robfig/cron/v3` (most battle-tested, simple API) or `go-co-op/gocron` (more features, fluent API). robfig is the older standard; gocron is newer and adds things like job names, tags, and distributed coordination. Pick based on which API style you prefer.
What cron syntax does robfig/cron use?
robfig/cron/v3 uses standard 5-field Unix cron by default. The earlier robfig/cron v1 used 6-field with seconds, which is a common porting bug. To use seconds in v3, parse with `cron.New(cron.WithSeconds())`. Otherwise v3 is 5-field, same as system crontab.
How do I set a timezone for my Go cron jobs?
Both libraries take a `*time.Location` for the scheduler. For robfig: `cron.New(cron.WithLocation(time.UTC))` or any other location. For gocron v2: `gocron.NewScheduler(gocron.WithLocation(time.UTC))` (location is an option, not a positional argument). Set this explicitly — the default is the Go process's local timezone, which on cloud containers is usually UTC but is a configuration choice you have to remember.
How do I handle graceful shutdown of a Go scheduler?
Both libraries return a context from `Stop()` that completes when in-flight jobs finish. In your shutdown handler, call `Stop()`, then wait for the returned context to be done (with a timeout to bound waiting). Pair with a signal handler for SIGTERM/SIGINT.
How do I run a Go cron job exactly once across multiple instances?
Built-in: you don't. Both robfig and gocron are in-process schedulers — every instance fires every job. Options: (1) enable the scheduler on one instance via an env var or feature flag, (2) acquire a distributed lock (Redis SETNX, etcd lease, etc.) at the start of each job, (3) use go-co-op/gocron's distributed lock support with a Redis/etcd locker, or (4) move scheduling out of Go to Kubernetes CronJob and have it invoke a Go HTTP endpoint.