Automate the Boring Stuff with Go, Ch. 13: Time, Scheduling and Running Programs
Part of Automate the Boring Stuff with Go, a beginner-friendly guide to programming in Go.
New in the 2026 edition.
Half of automation is “do this thing”, and the other half is “do it at nine every morning” or “do it after that other program finishes”. This chapter is the second half: telling time, waiting, repeating, and running other programs from Go. The code is under ch13.
Telling time
// now, formatting, parsing, and arithmetic with time
package main
import (
"fmt"
"os"
"time"
)
func main() {
now := time.Now()
fmt.Println("now:", now)
// Formatting uses a reference time, not %Y-%m-%d: Mon Jan 2 15:04:05 MST 2006.
// You write that moment in the layout you want, and Go copies the shape.
fmt.Println(now.Format("2006-01-02"))
fmt.Println(now.Format("Monday, 2 January 2006 at 3:04 PM"))
fmt.Println(now.Format(time.RFC3339)) // the layout to use when another program will read it
// Parsing is the same layout idea in reverse
due, err := time.Parse("2006-01-02 15:04", "2026-12-24 18:30")
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
// A Duration is a count of nanoseconds with a readable String
until := time.Until(due)
fmt.Printf("%s until %s (%.0f days)\n", until.Round(time.Minute), due.Format("Jan 2"), until.Hours()/24)
// Add, Sub, Before, After, Truncate
fmt.Println(due.Add(48 * time.Hour).Weekday())
fmt.Println(due.Sub(now) > 0, due.After(now))
fmt.Println(now.Truncate(time.Hour).Format(time.Kitchen))
}
The thing that surprises everyone: Go does not format dates with %Y-%m-%d. It uses a reference time, Mon Jan 2 15:04:05 MST 2006, and you write that exact moment in the shape you want. 2006-01-02 is “year dash month dash day”; 3:04 PM is “hour colon minute, twelve-hour clock”. Once it clicks, it is easier to read than format codes, and time.RFC3339 is the layout to use whenever another program will read the result. Parsing uses the same layouts.
A time.Duration is a count of nanoseconds with arithmetic and a readable String: 2 * time.Hour, d.Minutes(), d.Round(time.Second).
Waiting and repeating
// do something every so often: Sleep for a one-off pause, Ticker for a rhythm
package main
import (
"fmt"
"time"
)
func main() {
fmt.Println("pausing for half a second")
time.Sleep(500 * time.Millisecond)
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop() // always stop a ticker you are done with
deadline := time.After(1100 * time.Millisecond)
for {
select { // whichever happens first
case t := <-ticker.C:
fmt.Println("tick", t.Format("15:04:05.000"))
case <-deadline:
fmt.Println("done")
return
}
}
}
time.Sleep pauses once. time.NewTicker delivers a value on a channel at every interval, and select waits on several channels at once and takes whichever is ready first. That select is the shape of every “do something every N seconds until told to stop” loop in Go, and it comes back in chapter 14 with more going on in it.
Running other programs
A lot of boring work is already done by some program; you just need to call it. os/exec runs a command, captures its output, tells you its exit code, and can give it a deadline:
// run another program: capture its output, check its exit code, give it a deadline
package main
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"time"
)
func main() {
// a program's output as a string
out, err := exec.Command("go", "version").Output()
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
fmt.Printf("go says: %s", out)
// a failing program: the error carries the exit code
err = exec.Command("go", "run", "./does-not-exist").Run()
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
fmt.Println("exit code:", exitErr.ExitCode())
}
// a deadline: the context kills the command if it runs too long
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "help")
cmd.Stdout = nil // discard; set to os.Stdout to stream it to your terminal
if err := cmd.Run(); err != nil {
fmt.Println("go help:", err)
} else {
fmt.Println("go help finished within the deadline")
}
}
Output is for when you want what the program printed; Run when you only care whether it worked; cmd.Stdout = os.Stdout when you want to watch it. A non-zero exit code arrives as an *exec.ExitError, which is a result, not a crash. exec.CommandContext is the one to use for anything that might hang: when the context’s deadline passes, the command is killed.
One habit: pass arguments as separate strings, exec.Command("ls", "-l", dir), never as one shell line. There is no shell in between to split or expand anything, which is exactly why a file name with a space, or a ;, cannot hurt you.
Stopwatch
The first project is a stopwatch: press Enter for a lap, q to stop.
// stopwatch: press Enter for a lap, ctrl+c (or type q) to stop. Prints each lap's time
// and the running total, then a summary.
//
// go run ./ch13/projects/stopwatch
package main
import (
"bufio"
"fmt"
"io"
"os"
"strings"
"time"
)
// Lap is one press of Enter.
type Lap struct {
Number int
Split time.Duration // since the previous lap
Total time.Duration // since the start
}
// Format renders one lap line the way the program prints it.
func (l Lap) Format() string {
return fmt.Sprintf("Lap #%d: %6.2fs (%6.2fs total)", l.Number, l.Split.Seconds(), l.Total.Seconds())
}
// Run reads lap triggers from in until it closes or a line starting with q arrives, and
// writes lap lines to out. It is written against io.Reader so a test can drive it.
func Run(in io.Reader, out io.Writer, clock func() time.Time) []Lap {
start := clock()
last := start
var laps []Lap
sc := bufio.NewScanner(in)
for sc.Scan() {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(sc.Text())), "q") {
break
}
now := clock()
lap := Lap{Number: len(laps) + 1, Split: now.Sub(last), Total: now.Sub(start)}
laps = append(laps, lap)
fmt.Fprintln(out, lap.Format())
last = now
}
return laps
}
func main() {
fmt.Println("Press Enter for a lap, q then Enter to stop. Starting now.")
laps := Run(os.Stdin, os.Stdout, time.Now)
if len(laps) == 0 {
fmt.Println("no laps")
return
}
fastest := laps[0]
for _, l := range laps[1:] {
if l.Split < fastest.Split {
fastest = l
}
}
fmt.Printf("%d laps, fastest lap #%d at %.2fs\n", len(laps), fastest.Number, fastest.Split.Seconds())
}
The interesting line is the signature of Run: it takes the clock as a function. In main that is time.Now; in the test it is a fake that advances by a fixed amount each time it is read, so the test can check that two laps of 1.5 and 0.7 seconds print exactly Lap #2: 0.70s ( 2.20s total). You cannot test a stopwatch that reads the real clock. You can test one that asks for the time.
Countdown
The second counts down and then makes a noise:
// countdown counts down from N seconds, then gets your attention: it plays a sound where
// the operating system has a command-line player, and rings the terminal bell everywhere.
//
// go run ./ch13/projects/countdown -seconds 5
package main
import (
"flag"
"fmt"
"os"
"os/exec"
"runtime"
"time"
)
// AlarmCommand returns the command that makes a noise on this operating system, or nil
// when there is no safe default. The caller falls back to the terminal bell.
func AlarmCommand(goos string) *exec.Cmd {
switch goos {
case "darwin":
return exec.Command("afplay", "/System/Library/Sounds/Glass.aiff")
case "linux":
return exec.Command("paplay", "/usr/share/sounds/freedesktop/stereo/complete.oga")
case "windows":
return exec.Command("powershell", "-c", "[console]::beep(880,600)")
}
return nil
}
func run(args []string) error {
flags := flag.NewFlagSet("countdown", flag.ContinueOnError)
seconds := flags.Int("seconds", 10, "how long to count down")
if err := flags.Parse(args); err != nil {
return err
}
if *seconds < 1 {
return fmt.Errorf("-seconds must be at least 1")
}
for left := *seconds; left > 0; left-- {
fmt.Printf("\r%3d ", left) // \r returns to the start of the line, so the number updates in place
time.Sleep(time.Second)
}
fmt.Println("\rtime's up!")
fmt.Print("\a") // the terminal bell
if cmd := AlarmCommand(runtime.GOOS); cmd != nil {
if err := cmd.Run(); err != nil {
fmt.Fprintln(os.Stderr, "(no sound:", err, ")")
}
}
return nil
}
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
$ go run ./ch13/projects/countdown -seconds 3
time's up!
\r moves the cursor to the start of the line without a new line, which is how the number updates in place. The alarm is a different command per operating system, chosen with runtime.GOOS, and the terminal bell \a is the fallback that works everywhere a terminal does.
A small scheduler
The third project runs a command on an interval and logs every run. It is the smallest useful scheduler: for when cron is more ceremony than the job deserves and leaving a terminal open is fine.
// every runs a command on a fixed interval and logs each run: start, exit code, how long
// it took. It is the smallest useful scheduler, for when cron feels like too much and
// "leave a terminal open" is acceptable. Everything after -- is the command.
//
// go run ./ch13/projects/every -interval 1h -- go run ./ch11/projects/download-xkcd -n 1
// go run ./ch13/projects/every -interval 2s -times 3 -- date
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"time"
)
// RunOnce runs the command with a timeout and returns its exit code and duration. Output
// is streamed to out so a long job is visible while it runs.
func RunOnce(ctx context.Context, timeout time.Duration, out io.Writer, name string, args ...string) (code int, took time.Duration, err error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdout = out
cmd.Stderr = out
start := time.Now()
err = cmd.Run()
took = time.Since(start)
var exitErr *exec.ExitError
switch {
case ctx.Err() != nil:
return -1, took, fmt.Errorf("killed after %s: %w", timeout, ctx.Err()) // the deadline hit
case err == nil:
return 0, took, nil
case errors.As(err, &exitErr):
return exitErr.ExitCode(), took, nil // the command ran and failed; that is a result, not our error
default:
return -1, took, err // could not start at all
}
}
func run(args []string) error {
flags := flag.NewFlagSet("every", flag.ContinueOnError)
interval := flags.Duration("interval", time.Hour, "time between runs, e.g. 30m, 2h")
timeout := flags.Duration("timeout", 10*time.Minute, "kill a run that takes longer than this")
times := flags.Int("times", 0, "stop after this many runs (0 = forever)")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() == 0 {
return errors.New("usage: every [-interval 1h] [-timeout 10m] [-times N] -- <command> [args]")
}
name, cmdArgs := flags.Arg(0), flags.Args()[1:]
// ctrl+c cancels the context, which stops the loop and kills a running command
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
ticker := time.NewTicker(*interval)
defer ticker.Stop()
for n := 1; ; n++ {
fmt.Printf("[%s] run %d: %s %v\n", time.Now().Format(time.Kitchen), n, name, cmdArgs)
code, took, err := RunOnce(ctx, *timeout, os.Stdout, name, cmdArgs...)
if err != nil {
fmt.Printf("[%s] run %d could not complete after %s: %v\n", time.Now().Format(time.Kitchen), n, took.Round(time.Millisecond), err)
} else {
fmt.Printf("[%s] run %d exit %d after %s\n", time.Now().Format(time.Kitchen), n, code, took.Round(time.Millisecond))
}
if *times > 0 && n >= *times {
return nil
}
select {
case <-ticker.C:
case <-ctx.Done():
fmt.Println("stopped")
return nil
}
}
}
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
$ go run ./ch13/projects/every -interval 1s -times 2 -- date
[10:58AM] run 1: date []
Sat Oct 17 10:58:09 EDT 2026
[10:58AM] run 1 exit 0 after 3ms
[10:58AM] run 2: date []
Sat Oct 17 10:58:10 EDT 2026
[10:58AM] run 2 exit 0 after 3ms
Three things to take from it. flag.Duration accepts 30m and 2h directly. signal.NotifyContext turns ctrl+c into a cancelled context, which both ends the loop and kills whatever command is running at that moment, so nothing is left behind. And RunOnce separates three outcomes that are easy to blur: the command ran and said 0, the command ran and said something else (a result to log), and the command never completed (the deadline hit, or it could not start), which is the only one that is an error.
When to use the real scheduler
every is for today. For a job that should survive a reboot, hand the operating system a built binary from go build and let it do the scheduling:
- macOS and Linux:
crontab -e, then a line like0 9 * * * /Users/you/bin/download-xkcd -n 1 -dir /Users/you/xkcd. The five fields are minute, hour, day of month, month, day of week. On macOS, launchd is the native tool and cron still works. - Windows: Task Scheduler, Create Basic Task, pick the trigger, point it at your
.exe.
Two rules for scheduled programs: use absolute paths everywhere (the job does not run from your folder), and write a log file (chapter 15), because when it fails at 3 a.m. the terminal is not there to tell you.
Conclusion
Format with the reference time, measure with Duration, repeat with a ticker and a select, run other programs with os/exec and a context. With those, a Go program can be the glue between every other program on the machine, and the clock that runs them.