How to Use robfig/cron for Scheduling in Go

Use `robfig/cron` by creating a cron instance, adding jobs with standard cron expressions or custom parsers, and then running the scheduler in a separate goroutine.

The problem with sleeping forever

You are building a service that needs to run background tasks. Maybe it clears expired cache entries every hour. Maybe it sends a daily digest email at midnight. Your first instinct is probably a for loop with time.Sleep. That approach works until the sleep duration changes, or you need to pause a specific task, or you want to support standard cron syntax that your team already knows. Sleeping in a loop couples your business logic to timing mechanics. It also makes it hard to inspect, remove, or modify schedules while the program runs.

Go does not ship with a built-in scheduler. The standard library gives you time.Ticker and time.AfterFunc, which are great for simple intervals or one-off delays. When you need full cron-style scheduling with human-readable expressions, the community standard is github.com/robfig/cron/v3. It parses schedule strings, manages a priority queue of upcoming jobs, and dispatches them automatically. You define the job function and the schedule string. The library handles the rest.

How cron scheduling actually works in Go

A cron scheduler is essentially a loop that calculates when a job should run next, sleeps until that moment, and then executes the job. The library maintains a list of registered jobs. Each job has a schedule expression and a function to call. When the scheduler starts, it computes the next execution time for every registered job and sorts them by timestamp. It then waits until the earliest time arrives. When the time hits, it spawns a goroutine to run the job, recalculates the next run time for that job, and inserts it back into the sorted list.

The schedule strings follow the traditional Unix cron format: minute, hour, day of month, month, and day of week. The library supports wildcards, ranges, and step values. You can also swap in a custom parser if you need second-level precision or support for descriptive names like @daily or @every 1h. The parser is just a configuration option passed to the constructor. It does not change the core execution model.

Schedulers are cheap to run. They consume almost no CPU while waiting for the next tick. The real cost comes from the jobs themselves. If a job takes longer than its interval, the scheduler will still trigger it again. By default, robfig/cron allows concurrent execution of the same job. You can change that behavior with a chain option, but the default matches how most background workers expect to behave.

Background timing is a coordination problem. Let the library handle the queue.

A minimal scheduler

Here is the simplest way to spin up a scheduler, register two tasks, and keep the program alive.

package main

import (
	"fmt"
	"log"
	"time"

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

func main() {
	// Create a fresh scheduler instance with default parser
	c := cron.New()

	// Register a function to run every five minutes
	_, err := c.AddFunc("*/5 * * * *", func() {
		fmt.Println("Five-minute check:", time.Now())
	})
	// Check registration error to catch malformed expressions early
	if err != nil {
		log.Fatal(err)
	}

	// Register a daily task at 9:30 AM
	_, err = c.AddFunc("30 9 * * *", func() {
		fmt.Println("Daily report generated")
	})
	// Fail fast if the schedule string is invalid
	if err != nil {
		log.Fatal(err)
	}

	// Start the background event loop in a new goroutine
	c.Start()

	// Block the main goroutine so the process does not exit
	select {}
}

The AddFunc method takes a schedule string and a function with no arguments and no return values. It returns a job ID and an error. The ID is an integer that the scheduler assigns internally. You can ignore it if you never plan to remove the job. The error tells you if the schedule string is malformed.

Calling c.Start() launches the scheduler loop in a separate goroutine. The main function continues running immediately. The select {} statement blocks forever, which keeps the process alive. In a real application, you would replace that with a signal handler or a server shutdown routine.

Schedulers run in the background. Blocking the main thread is expected behavior.

What happens under the hood

When c.Start() executes, the library creates a time.Timer that fires at the next scheduled job time. It blocks on that timer. When the timer fires, the scheduler checks the current time, finds all jobs due, and dispatches them. Each job runs in its own goroutine. The scheduler then recalculates the next run time for every active job and resets the main timer.

This design means the scheduler itself never blocks on your job logic. If your five-minute check takes ten minutes to complete, the scheduler will still wake up at the next five-minute mark and launch another goroutine for it. That is usually what you want. Background tasks should be independent. If you need to prevent overlapping runs, you must wrap the job with a chain option that enforces single execution.

The parser runs once during registration. It converts the string into an internal representation that can quickly calculate the next occurrence. This keeps the runtime loop fast. The scheduler does not re-parse strings on every tick. It only does math on time values. The priority queue uses a binary heap, so insertion and extraction stay logarithmic even with hundreds of jobs.

Time math is cheap. Parsing is expensive. Do it once.

Managing jobs in a real application

Production code rarely leaves jobs running forever without oversight. You usually need to track job IDs, remove tasks dynamically, and shut down the scheduler cleanly when the application stops. Here is how that looks with proper lifecycle management.

package main

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

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

// runCleanup performs periodic database maintenance
func runCleanup() {
	fmt.Println("Running cleanup routine")
}

func main() {
	// Initialize scheduler with standard parsing rules
	c := cron.New()

	// Add a job and capture its identifier for later removal
	id, err := c.AddJob("*/10 * * * *", cron.FuncJob(runCleanup))
	// Verify the job registered successfully
	if err != nil {
		log.Fatal(err)
	}

	// Start the scheduler loop
	c.Start()

	// Wait for termination signal using context
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	<-ctx.Done()

	// Remove the specific job before shutting down
	c.Remove(id)
	fmt.Println("Scheduled job removed")

	// Stop the scheduler and wait for running jobs to finish
	c.Stop()
	fmt.Println("Scheduler stopped")
}

The AddJob method accepts a cron.Job interface instead of a raw function. cron.FuncJob wraps your function into that interface. This gives you the job ID upfront. You can store that ID in a database, a config file, or an in-memory map. When you call c.Remove(id), the scheduler takes the job out of its internal queue. It will not fire again.

The shutdown sequence uses signal.NotifyContext to listen for SIGINT and SIGTERM. When the signal arrives, the context cancels and the main goroutine proceeds to cleanup. Calling c.Stop() drains the scheduler loop. It waits for any currently running jobs to finish before returning. This prevents goroutine leaks and ensures graceful termination.

If you need second-level precision, you must override the default parser. The standard parser only supports five fields. Passing cron.WithParser(cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)) to cron.New() enables six-field expressions and descriptive aliases. The parser option is evaluated at construction time. Changing it later requires creating a new scheduler instance.

Context is plumbing. Run it through every long-lived call site.

Handling concurrency and overlapping runs

Jobs that take longer than their interval create overlapping executions. The default behavior allows this. A database cleanup that runs every ten minutes might still be running when the next ten-minute mark arrives. The scheduler launches a second goroutine. Your code now has two cleanup routines touching the same tables.

You can prevent this with cron.WithChain. Chains wrap jobs with middleware-like behavior. cron.SkipIfStillRunning(cron.DefaultLogger) checks if a job is already active before launching a new one. If it is, the scheduler skips the trigger and logs a warning. This keeps your database safe from concurrent writes.

// Wrap the scheduler with a chain that prevents overlapping runs
c := cron.New(cron.WithChain(cron.SkipIfStillRunning(cron.DefaultLogger)))

The chain option applies to all jobs added to that scheduler instance. If you need different behavior for different jobs, create separate scheduler instances or manage concurrency manually with sync.Mutex or channels inside the job function. The library does not enforce mutual exclusion by default. It trusts your code to handle its own state.

Concurrency is a design choice. Enforce it explicitly.

Common traps and compiler complaints

The most frequent mistake is forgetting to start the scheduler. If you call AddFunc but skip c.Start(), the program compiles fine. The jobs simply never run. The scheduler sits idle until you explicitly tell it to begin.

Another common issue is blocking the scheduler goroutine. The library dispatches jobs in separate goroutines, so your job function should not call c.Stop() or modify the scheduler while it is running. Doing so can cause deadlocks. The compiler will not catch this. You will see the program hang indefinitely.

Parser mismatches cause silent failures. The default parser rejects second-level expressions. If you pass "*/5 * * * * *" (six fields) to a standard scheduler, the registration fails. The error returns from AddFunc or AddJob. If you ignore the error, the job never registers. The compiler rejects programs that ignore errors with err declared and not used. Always check the return value. The community accepts the if err != nil { return err } boilerplate because it makes the unhappy path visible.

Job functions that panic will crash the specific goroutine running that job. The scheduler catches panics by default and logs them, but the panic still terminates that execution path. If your job holds a lock or writes to a channel, a panic can leave shared state in an inconsistent condition. Wrap long-running jobs with defer recover() if you need to guarantee cleanup.

The compiler complains with cannot use func() as cron.Job in argument if you pass a raw function to AddJob without wrapping it in cron.FuncJob. The type system enforces the interface contract. Wrap the function or use AddFunc instead.

The worst goroutine bug is the one that never logs.

When to reach for a cron library

Use robfig/cron when you need human-readable schedule strings and want to manage multiple recurring tasks in a single process. Use a simple time.Ticker when you only need a fixed interval and want to avoid third-party dependencies. Use time.AfterFunc when you need a one-off delayed execution that you can cancel. Use a dedicated job queue like RabbitMQ or Redis Streams when tasks must survive process restarts or distribute across multiple machines. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.

Schedulers are for recurring work. Queues are for durable work. Pick the tool that matches your failure tolerance.

Where to go next