# Golang Cron Jobs: robfig/cron, gocron, and Monitoring Each Run

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/go-cron-jobs

> Schedule Go jobs with robfig/cron v3 or gocron v2, or run a binary from system cron: specs, time zones, panics, overlap, replicas, and alerts on missed runs.

**To run a cron job in Go, schedule a function inside your service with `github.com/robfig/cron/v3` or `github.com/go-co-op/gocron/v2`, or build a binary that does one run and exits and let system cron start it.** robfig/cron v3 reads standard five-field specs such as `c.AddFunc("30 2 * * *", fn)` in the machine's local time zone, and gocron v2 parses its cron jobs with robfig's parser. Both keep schedules only in memory, run jobs in goroutines, and run every job in every replica unless you add a lock or leader election. A panic in a job can end the whole process, and a run that never starts leaves no error behind.

Behavior below is read from the source of robfig/cron v3.0.1, gocron v2.22.0, and Go 1.27.3, checked October 25, 2026.

## robfig/cron v3 Reads Five Fields in Local Time

`cron.New()` parses minute, hour, day of month, month, and day of week, plus descriptors such as `@daily` and `@every 1h30m` ([parser.go](https://github.com/robfig/cron/blob/v3.0.1/parser.go#L217-L230)). v3 dropped the optional leading seconds field that v1 accepted ([README](https://github.com/robfig/cron/blob/v3.0.1/README.md#L62-L75)), so a six-field spec from older code now fails in `AddFunc` with `expected exactly 5 fields` ([parser.go](https://github.com/robfig/cron/blob/v3.0.1/parser.go#L185-L190)). `cron.New(cron.WithSeconds())` makes seconds the first, required field ([option.go](https://github.com/robfig/cron/blob/v3.0.1/option.go#L17-L23)).

`@every 10m` counts from when the job is added or the scheduler starts, not from the clock, and measures start to start ([doc.go](https://github.com/robfig/cron/blob/v3.0.1/doc.go#L120-L135)). When both day fields are restricted, a date matching either one runs, as in Linux cron ([spec.go](https://github.com/robfig/cron/blob/v3.0.1/spec.go#L177-L188)).

Specs run in `time.Local` unless you pass `cron.WithLocation(time.UTC)`. One spec can override that with a `CRON_TZ=America/New_York` prefix, and `TZ=` still works for compatibility ([doc.go](https://github.com/robfig/cron/blob/v3.0.1/doc.go#L137-L170)). A prefixed zone is loaded when `AddFunc` parses the spec ([parser.go](https://github.com/robfig/cron/blob/v3.0.1/parser.go#L93-L103)), so in an image with no time zone database, such as one built `FROM scratch`, it returns `provided bad location`. Importing `_ "time/tzdata"` embeds the database, about 450 KB ([time/tzdata](https://pkg.go.dev/time/tzdata)).

In a zone with daylight saving time, jobs in the spring-forward gap "will not be run", the docs warn, and because `Next` walks forward in real time ([spec.go](https://github.com/robfig/cron/blob/v3.0.1/spec.go#L138-L160)), a time inside the repeated fall-back hour matches twice. [Cron time zones](https://cronradar.com/blog/cron-timezone) covers choosing a zone; UTC avoids both.

## Recover Panics and Stop Overlap With Job Wrappers

v3 "will no longer recover panics in jobs that it runs" ([README](https://github.com/robfig/cron/blob/v3.0.1/README.md#L89-L98)). The doc comment on `New` still says the default chain "recovers panics and logs them to stderr", but the code builds an empty chain ([cron.go](https://github.com/robfig/cron/blob/v3.0.1/cron.go#L108-L126)). Each run starts in a new goroutine ([cron.go](https://github.com/robfig/cron/blob/v3.0.1/cron.go#L307-L314)), and a panic nobody recovers terminates the program ([Go spec](https://go.dev/ref/spec#Handling_panics)) with exit status 2 ([runtime/panic.go](https://github.com/golang/go/blob/go1.27.3/src/runtime/panic.go#L1470-L1508)), and every other job in the process with it.

Runs also overlap by default: a 10-minute job on a 5-minute schedule runs two copies at once. Three wrappers in [chain.go](https://github.com/robfig/cron/blob/v3.0.1/chain.go) handle both problems. `cron.Recover(logger)` recovers and logs the panic with its stack, `cron.SkipIfStillRunning(logger)` skips a run while the job's previous run is still going, and `cron.DelayIfStillRunning(logger)` makes it wait instead. Put `Recover` inside `SkipIfStillRunning`:

```go
logger := cron.PrintfLogger(log.New(os.Stderr, "cron: ", log.LstdFlags))
c := cron.New(
	cron.WithLocation(time.UTC),
	cron.WithChain(cron.SkipIfStillRunning(logger), cron.Recover(logger)),
)
```

`WithChain(a, b)` wraps each job as `a(b(job))`. In v3.0.1, `SkipIfStillRunning` returns its "running" token only when the job returns normally, so a panic never hands it back ([chain.go](https://github.com/robfig/cron/blob/v3.0.1/chain.go#L76-L92)). Put `Recover` outside it, and after the first panic every later run of that job is skipped until the process restarts. The [fix](https://github.com/robfig/cron/commit/bc59245fe10efaed9d51b56900192527ed733435) reached master in January 2021 but no tagged release. Each skip is logged at Info, which `PrintfLogger` discards and `VerbosePrintfLogger` prints ([logger.go](https://github.com/robfig/cron/blob/v3.0.1/logger.go#L26-L50)). The default logger writes to stdout ([logger.go](https://github.com/robfig/cron/blob/v3.0.1/logger.go#L12)), so pass your own.

Both overlap wrappers act on one job in one process. They don't stop a second replica; [preventing overlapping cron jobs](https://cronradar.com/blog/prevent-overlapping-cron-jobs) covers locks that do.

`c.Stop()` stops scheduling and returns a context that is done when running jobs finish ([cron.go](https://github.com/robfig/cron/blob/v3.0.1/cron.go#L321-L336)). It doesn't cancel them, so give jobs a context you cancel at shutdown.

## gocron v2: Singleton Mode, Listeners, and Multiple Instances

gocron v2.22.0, released July 9, 2026, depends on robfig/cron v3.0.1 ([go.mod](https://github.com/go-co-op/gocron/blob/v2.22.0/go.mod#L8)). `gocron.CronJob("30 2 * * *", false)` takes five fields; `true` allows an optional leading seconds field ([job.go](https://github.com/go-co-op/gocron/blob/v2.22.0/job.go#L232-L276)). Interval jobs use `gocron.DurationJob(10*time.Minute)`. The scheduler runs in `time.Local` unless you pass `gocron.WithLocation`; a `CRON_TZ=` prefix in the spec wins. Where it differs from robfig:

- **Overlap.** `gocron.WithSingletonMode(gocron.LimitModeReschedule)` skips a run while the previous one runs. `LimitModeWait` queues it, and the docs warn that a frequent job "may pile up in the wait queue and be executed many times back to back" ([scheduler.go](https://github.com/go-co-op/gocron/blob/v2.22.0/scheduler.go#L1198-L1234)).
- **Panics.** gocron recovers a panic only when the job has an `AfterJobRunsWithPanic` listener; otherwise it calls the task directly ([executor.go](https://github.com/go-co-op/gocron/blob/v2.22.0/executor.go#L523-L582)).
- **Errors.** A task can return an `error`, which goes to `AfterJobRunsWithError`. `BeforeJobRuns`, `AfterJobRuns`, and `AfterLockError` are the other listeners ([job.go](https://github.com/go-co-op/gocron/blob/v2.22.0/job.go#L980-L1061)).
- **Shutdown.** A task whose first parameter is a `context.Context` receives one that `Shutdown()` cancels. `Shutdown()` waits for running jobs up to the stop timeout, 10 seconds unless you set `WithStopTimeout`, then returns `ErrStopSchedulerTimedOut` ([scheduler.go](https://github.com/go-co-op/gocron/blob/v2.22.0/scheduler.go#L1094-L1103)).
- **Start time.** `gocron.WithStartAt(gocron.WithStartImmediately())` runs a job when the scheduler starts, then on its schedule.

For several instances, `gocron.WithDistributedElector` runs jobs only where `IsLeader` returns nil, and `gocron.WithDistributedLocker` makes each run take a lock and skips the run when it can't ([executor.go](https://github.com/go-co-op/gocron/blob/v2.22.0/executor.go#L443-L474)). The lock key is the job's name, which defaults to the Go function's name, so set `WithName`. The lock is held only while the job runs, and the docs leave it to the locker implementation to handle "time splay between schedulers" ([distributed.go](https://github.com/go-co-op/gocron/blob/v2.22.0/distributed.go#L16-L39)). [Distributed cron](https://cronradar.com/blog/distributed-cron) compares leader election and locks across stacks.

## Run a Go Binary From System Cron

When cron, a systemd timer, or a Kubernetes CronJob owns the schedule, the program does one run and exits. Three Go details decide whether failures show:

- **The exit status is the result.** Return nonzero on failure. [`os.Exit`](https://pkg.go.dev/os#Exit) terminates "immediately; deferred functions are not run", and [`log.Fatal`](https://pkg.go.dev/log#Fatal) calls it, so return a code from a `run` function and call `os.Exit` only in `main`.
- **SIGTERM makes a Go program exit by default** ([os/signal](https://pkg.go.dev/os/signal)), with no chance to report. [`signal.NotifyContext`](https://pkg.go.dev/os/signal#NotifyContext) turns it into a canceled context. GNU `timeout` sends SIGTERM when its limit passes, follows with SIGKILL after `--kill-after`, and exits 124 itself ([timeout(1)](https://man7.org/linux/man-pages/man1/timeout.1.html), coreutils 9.11).
- **Log to stderr.** The `log` package's standard logger already does ([log](https://pkg.go.dev/log)).

## What In-Process Schedulers Don't Do

- **Persist or catch up.** On start, robfig computes each next run from now ([cron.go](https://github.com/robfig/cron/blob/v3.0.1/cron.go#L242-L247)), and gocron moves a past start time beyond now ([scheduler.go](https://github.com/go-co-op/gocron/blob/v2.22.0/scheduler.go#L704-L730)). A 02:30 run due while a deploy restarts the service is skipped, not delayed. Compare [what each scheduler does after downtime](https://cronradar.com/blog/missed-runs-after-downtime).
- **Run once per cluster.** Without gocron's elector or locker, or a leader you elect, three replicas run each job three times.
- **Bound a run.** Neither library has a per-run timeout. Deadlines come from contexts the job honors; [cron job timeouts](https://cronradar.com/blog/cron-job-timeout) covers finding the call that hangs.
- **Notice a run that didn't happen.** A stopped process, a job never registered, or the stuck skip token above produces no error and no log line.

## Monitor Each Run With HTTP Check-Ins

There is no CronRadar SDK for Go and no auto-discovery of robfig or gocron jobs, so each job reports with its own HTTP check-in. This helper uses only the standard library; `crypto/rand.Text` needs Go 1.24 or later:

```go
// checkin.go: CronRadar check-ins over HTTP.
package main

import (
	"context"
	"crypto/rand"
	"log"
	"net/http"
	"net/url"
	"os"
	"strings"
	"time"
)

// monitored reports start, runs fn, then reports complete or fail. It returns
// fn's error unchanged, and a panic is reported as fail and re-raised with the
// same value. schedule is five-field cron in UTC; CronRadar uses it to create
// the monitor on the first check-in.
func monitored(key, schedule string, fn func() error) error {
	c := checkin{key: key, schedule: schedule, runID: rand.Text()}
	c.send("start", "")
	defer func() {
		if p := recover(); p != nil {
			c.send("fail", "job panicked")
			panic(p)
		}
	}()
	if err := fn(); err != nil {
		c.send("fail", "job returned an error") // the details stay in your logs
		return err
	}
	c.send("complete", "")
	return nil
}

type checkin struct{ key, schedule, runID string }

// send makes one check-in. Problems are logged to stderr, never returned.
func (c checkin) send(event, message string) {
	apiKey := os.Getenv("CRONRADAR_API_KEY")
	if apiKey == "" {
		log.Printf("cronradar: CRONRADAR_API_KEY is not set; skipped %s for %s", event, c.key)
		return
	}
	q := url.Values{"schedule": {c.schedule}, "run_id": {c.runID}}
	if message != "" {
		q.Set("message", message)
	}
	// Encode writes spaces as "+"; send them as %20, like curl --data-urlencode.
	endpoint := "https://cron.life/api/v1/ping/" + url.PathEscape(c.key) + "/" + event +
		"?" + strings.ReplaceAll(q.Encode(), "+", "%20")

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
	if err != nil {
		log.Printf("cronradar: %s for %s: %v", event, c.key, err)
		return
	}
	req.SetBasicAuth(apiKey, "") // API key as the username, empty password
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		log.Printf("cronradar: %s for %s: %v", event, c.key, err)
		return
	}
	resp.Body.Close()
	if resp.StatusCode >= 300 {
		log.Printf("cronradar: %s for %s: HTTP %d", event, c.key, resp.StatusCode)
	}
}
```

Each check-in gets five seconds from [`context.WithTimeout`](https://pkg.go.dev/context#WithTimeout), on a background context so a canceled job can still report `fail`. Errors are logged and dropped, so an unreachable cron.life can't stall or fail a run. Re-raising the panic leaves its handling to whatever handled it before: a `Recover` wrapper, a panic listener, or a crash. The fail message is fixed text because error strings often carry IDs or credentials.

The job in all three examples deletes old export files and checks its context between files, so a deadline or shutdown ends it with an error:

```go
// prune.go
package main

import (
	"context"
	"os"
	"path/filepath"
	"time"
)

func pruneExports(ctx context.Context, dir string, maxAge time.Duration) error {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return err
	}
	cutoff := time.Now().Add(-maxAge)
	for _, entry := range entries {
		if err := ctx.Err(); err != nil {
			return err
		}
		info, err := entry.Info()
		if err != nil {
			return err
		}
		if info.Mode().IsRegular() && info.ModTime().Before(cutoff) {
			if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil {
				return err
			}
		}
	}
	return nil
}
```

### robfig/cron v3 Service

```go
// main.go: github.com/robfig/cron/v3 v3.0.1, plus checkin.go and prune.go.
package main

import (
	"context"
	"log"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/robfig/cron/v3"
)

const pruneSchedule = "30 2 * * *" // 02:30 UTC; cron and CronRadar read it the same way

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	logger := cron.PrintfLogger(log.New(os.Stderr, "cron: ", log.LstdFlags))
	c := cron.New(
		cron.WithLocation(time.UTC),
		cron.WithChain(cron.SkipIfStillRunning(logger), cron.Recover(logger)),
	)

	_, err := c.AddFunc(pruneSchedule, func() {
		err := monitored("prune-exports", pruneSchedule, func() error {
			runCtx, cancel := context.WithTimeout(ctx, 20*time.Minute)
			defer cancel()
			return pruneExports(runCtx, "/var/lib/billing/exports", 30*24*time.Hour)
		})
		if err != nil {
			log.Printf("prune-exports: %v", err) // a cron.Job can't return it
		}
	})
	if err != nil {
		log.Fatal(err)
	}

	c.Start()
	<-ctx.Done()      // SIGINT or SIGTERM: running jobs see ctx canceled
	<-c.Stop().Done() // schedule nothing new and wait for running jobs
}
```

The job's deadline derives from the shutdown context, so a run interrupted by a deploy reports `fail` instead of vanishing mid-run.

### gocron v2 Service

```go
// main.go: github.com/go-co-op/gocron/v2 v2.22.0, plus checkin.go and prune.go.
package main

import (
	"context"
	"log"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/go-co-op/gocron/v2"
	"github.com/google/uuid"
)

const pruneSchedule = "30 2 * * *" // 02:30 UTC

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	s, err := gocron.NewScheduler(gocron.WithLocation(time.UTC))
	if err != nil {
		log.Fatal(err)
	}

	_, err = s.NewJob(
		gocron.CronJob(pruneSchedule, false),
		gocron.NewTask(func(jobCtx context.Context) error { // Shutdown cancels jobCtx
			return monitored("prune-exports", pruneSchedule, func() error {
				runCtx, cancel := context.WithTimeout(jobCtx, 20*time.Minute)
				defer cancel()
				return pruneExports(runCtx, "/var/lib/billing/exports", 30*24*time.Hour)
			})
		}),
		gocron.WithName("prune-exports"),
		gocron.WithSingletonMode(gocron.LimitModeReschedule),
		gocron.WithEventListeners(
			gocron.AfterJobRunsWithError(func(_ uuid.UUID, name string, err error) {
				log.Printf("%s: %v", name, err)
			}),
			gocron.AfterJobRunsWithPanic(func(_ uuid.UUID, name string, value any) {
				log.Printf("%s panicked: %v", name, value)
			}),
		),
	)
	if err != nil {
		log.Fatal(err)
	}

	s.Start()
	<-ctx.Done()
	if err := s.Shutdown(); err != nil { // waits up to 10 seconds for running jobs
		log.Print(err)
	}
}
```

The panic listener is what makes gocron recover. A recovered panic also reaches `AfterJobRunsWithError` as `ErrPanicRecovered`, so it's logged twice.

### Go Binary Run by System Cron

```go
// main.go: one run per invocation, plus checkin.go and prune.go.
package main

import (
	"context"
	"log"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	os.Exit(run()) // os.Exit skips deferred calls, so they all live in run
}

func run() int {
	// SIGTERM, from timeout(1) below or a stop request, cancels ctx instead of
	// killing the process, so the job stops and reports fail.
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	ctx, cancel := context.WithTimeout(ctx, 20*time.Minute)
	defer cancel()

	err := monitored("prune-exports", "30 2 * * *", func() error {
		return pruneExports(ctx, "/var/lib/billing/exports", 30*24*time.Hour)
	})
	if err != nil {
		log.Printf("prune-exports: %v", err)
		return 1
	}
	return 0
}
```

```text
30 2 * * * timeout --kill-after=1m 25m /usr/local/bin/prune-exports
```

The crontab time is in the cron daemon's zone, so this matches the reported schedule only on a UTC host. Cron starts jobs with a minimal environment, so make sure `CRONRADAR_API_KEY` is set for the job itself; without it, the helper logs a warning and the job runs unmonitored. `timeout` is the backstop for a call that ignores its context.

### How CronRadar Reads These Runs

Give each job its own monitor key. Because every check-in carries `schedule`, the first run creates the monitor, and a deleted monitor is recreated by the next run. `schedule` applies only when a check-in creates the monitor; change it later in the dashboard's monitor settings. Use one CronRadar application and key per environment. Each run makes two requests against limits of 60 a minute and 1,000 an hour per key, so many every-minute jobs on one key can draw `429` responses.

A `fail` alerts immediately. Missed runs are checked once a minute: a monitor turns `warning` once its expected time passes and `critical`, with an incident, once it's past the grace period (90 seconds by default) and at least two minutes late. That catches a dead process, a job dropped from the code, and the stuck skip token alike. Pause or delete a job's monitor when you retire the job.

Stuck detection needs the `start`. Every check-in moves the expected time to the next occurrence; if that time plus grace passes while the last event is a `start`, the monitor goes `critical` as stuck. With `SkipIfStillRunning` or singleton mode, a hung run blocks its own next run, so the monitor flags it after that occurrence plus grace. Without them, the next run's `start` resets the window and a hang can go unflagged. There's no per-job maximum runtime, which is why each example gives the job a context deadline that turns a hang into a `fail`.

### Convert Go Specs to Five-Field UTC

CronRadar reads [standard five-field cron in UTC](https://docs.cronradar.com/schedules), without seconds, `?`, descriptors, or intervals, and expects a run only on dates matching both day fields when both are restricted:

| Go spec                                                       | CronRadar `schedule`                                                                       |
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `30 2 * * *` with `WithLocation(time.UTC)`                    | `30 2 * * *`                                                                               |
| `0 30 2 * * *` with `WithSeconds()` or `CronJob(spec, true)`  | `30 2 * * *`                                                                               |
| `@daily`, `@hourly`, `@weekly` in UTC                         | `0 0 * * *`, `0 * * * *`, `0 0 * * 0`                                                      |
| `0 9 ? * MON-FRI`                                             | `0 9 * * MON-FRI`                                                                          |
| `CRON_TZ=America/New_York 0 9 * * 1-5`                        | `0 13 * * 1-5`, then raise the monitor's grace to at least 3,600 seconds plus the run time |
| `0 9 1-7 * 1` (Go runs it on the 1st through 7th and Mondays) | Two jobs with their own keys: `0 9 1-7 * *` and `0 9 * * 1`                                |
| `*/30 * * * * *`                                              | None: five fields can't express more than one run a minute                                 |
| `@every 10m` or `DurationJob(10*time.Minute)`                 | None: switch to `*/10 * * * *`, which runs on the clock                                    |

09:00 in New York is 13:00 UTC in summer and 14:00 in winter, so the earlier time plus an hour of grace covers both, at the cost of a `warning` before each winter run.

## Frequently Asked Questions

### Does robfig/cron support seconds?

Yes, as an opt-in. `cron.New(cron.WithSeconds())` requires a leading seconds field, and a custom parser built with `cron.SecondOptional` accepts five or six fields. The default v3 parser accepts exactly five, unlike v1. gocron's `CronJob(spec, true)` makes the seconds field optional.

### Is robfig/cron still maintained?

Its latest release is v3.0.1, tagged January 4, 2020 ([Go module proxy](https://proxy.golang.org/github.com/robfig/cron/v3/@v/v3.0.1.info)). The [last commit](https://github.com/robfig/cron/commits/master) to master is from January 6, 2021, and 174 issues and pull requests were open on October 25, 2026. The repository isn't archived, and Kubernetes 1.37.1 ([go.mod](https://github.com/kubernetes/kubernetes/blob/v1.37.1/go.mod#L50)) and gocron v2.22.0 both depend on v3.0.1. gocron v2, by contrast, shipped five [releases](https://github.com/go-co-op/gocron/releases) between April and July 2026.

### How do I stop a Go cron job from overlapping?

With robfig/cron, add `cron.SkipIfStillRunning` or `cron.DelayIfStillRunning` through `cron.WithChain`, listed before `cron.Recover`. With gocron, use `gocron.WithSingletonMode(gocron.LimitModeReschedule)` to skip or `LimitModeWait` to queue. Both guard one process only; replicas need a shared lock or a single leader.

### How do I run a Go cron job in a specific time zone?

Pass `cron.WithLocation(loc)` or `gocron.WithLocation(loc)` for every job, or prefix one spec with `CRON_TZ=Europe/Berlin`. Import `time/tzdata` when the image has no zone database. Zones with daylight saving time skip or repeat runs at the changeover, so prefer UTC for jobs that must run exactly once a day.

### How do I run Go cron jobs on multiple instances without duplicates?

Run the scheduler only in an elected leader, use gocron's `WithDistributedElector` or `WithDistributedLocker`, or move the schedule out of the service into a Kubernetes CronJob or system cron that runs the binary once. Each of these can still run an occurrence twice or skip one in some failure, so make the job safe to repeat.
