How to Run Scheduled Tasks (Cron Jobs) in Go

Run scheduled tasks in Go by installing the robfig/cron library and adding a job with a cron expression string.

The clock is ticking

You built a service that processes orders. It works great. Now the product manager asks for a nightly cleanup of expired sessions and a morning report email. The server runs continuously, but these tasks need a schedule. Go does not include a cron scheduler in the standard library. You need a pattern or a library to make time-based execution happen without blocking your main loop.

What a scheduler actually does

A cron scheduler watches a clock and triggers functions at specific times. In Go, concurrency is cheap, so a scheduler usually runs as a background goroutine. It loops, checks if a job is due, runs the job, and waits for the next tick. The standard library has time.Ticker for simple intervals, but complex schedules require parsing cron expressions. The community standard is robfig/cron. It handles the parsing, timezone logic, and concurrent execution safely.

The string 0 0 * * * is a cron expression. It has five fields: minute, hour, day of month, month, and day of week. An asterisk means every value. So 0 0 * * * means minute 0 of hour 0, every day. The library parses this string and calculates the next run time.

The minimal cron setup

Here is the simplest way to run a task every minute using the community standard library.

package main

import (
	"fmt"
	"time"

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

func main() {
	// Create a new cron scheduler instance.
	c := cron.New()

	// Add a job that runs every minute.
	// The string is a standard cron expression.
	c.AddFunc("* * * * *", func() {
		fmt.Println("Running scheduled task at", time.Now())
	})

	// Start the scheduler in the background.
	c.Start()

	// Keep the main goroutine alive so the scheduler can run.
	// In a real app, this would be a server loop or a signal handler.
	select {}
}

How the scheduler runs

The cron.New() call allocates the scheduler state. AddFunc parses the cron string. If the string is malformed, the library panics immediately with cron: invalid expression. This is a safety feature to catch configuration errors early. Start() launches a background goroutine that loops and checks the schedule. The select {} block blocks the main goroutine indefinitely. Without it, main returns, the process exits, and the scheduler dies before running anything.

Goroutines are cheap. The scheduler runs in its own goroutine, so your main function can do other work. In a web server, you would start the scheduler alongside the HTTP listener. The scheduler runs independently until the process stops.

One-off timers in the standard library

You do not always need a full cron library. If you just need to run something once after a delay, use time.AfterFunc. It is built into the standard library and runs the function in a new goroutine.

package main

import (
	"fmt"
	"time"
)

func main() {
	// Run a task once after 5 seconds.
	// The function executes in a new goroutine.
	time.AfterFunc(5*time.Second, func() {
		fmt.Println("Delayed task ran")
	})

	// Wait for the task to complete.
	time.Sleep(6 * time.Second)
}

Use time.AfterFunc for simple delays. It does not support recurring schedules or cron expressions. It is perfect for retry logic or deferred cleanup.

Real-world jobs need context

Real services need to stop cleanly. If the process restarts, you do not want the scheduler to hang or leave jobs half-done. Use context.Context to signal shutdown. The convention is to pass ctx as the first argument to functions that might block. Name it ctx.

Here is how you set up graceful shutdown with a signal handler.

// Setup graceful shutdown with context.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
	// Wait for interrupt signal.
	<-sigChan
	cancel()
}()

Now wire the context into the scheduler.

c := cron.New()

// Run daily at 2 AM.
c.AddFunc("0 2 * * *", func() {
	// Pass context to the job function.
	CleanupTask(ctx)
})

c.Start()
<-ctx.Done()
c.Stop()

The job function should check the context before doing work.

// CleanupTask performs the scheduled maintenance work.
// It accepts a context to respect cancellation signals.
func CleanupTask(ctx context.Context) {
	// Check context before starting expensive work.
	select {
	case <-ctx.Done():
		log.Println("Cleanup skipped: context cancelled")
		return
	default:
	}

	log.Println("Cleaning up temporary files...")
	// Simulate work.
	time.Sleep(500 * time.Millisecond)
	log.Println("Cleanup complete.")
}

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

Pitfalls and runtime surprises

Timezones are the enemy of scheduled tasks. The robfig/cron scheduler runs in UTC by default. If you schedule 0 9 * * * thinking it is 9 AM your time, it runs at 9 AM UTC. Use cron.WithLocation(time.Local) if you want local time.

c := cron.New(cron.WithLocation(time.Local))

Jobs are functions. If a job fails, the scheduler does not know unless you log it. The convention is to handle errors inside the job function and log them. Returning an error from a cron job function is ignored by the scheduler. The worst goroutine bug is the one that never logs.

Long jobs can cause issues. If a job takes longer than the interval, the next run waits. By default, robfig/cron skips overlapping runs. This prevents pile-up but might delay work. If you need to run jobs concurrently, configure the scheduler with cron.WithChain().

Invalid expressions panic at runtime. The compiler does not check cron strings. Validate expressions in tests. If you pass a bad string, the program crashes with cron: invalid expression.

Choosing the right tool

Use robfig/cron when you need complex schedules like "every Monday at 9 AM" or "every 15 minutes between 9 and 5". Use time.Ticker when you just need a fixed interval like "every 30 seconds" and want to avoid a dependency. Use an external cron daemon when you need the scheduler to survive application restarts and run independently of the process lifecycle. Use a database queue when multiple instances of your app need to coordinate so only one instance runs the job. Use time.AfterFunc when you need a one-off delay without recurring execution.

Where to go next