Question

Handle SIGINT and Cleanup on Ctrl+C in Go

gosignalssigterm

Question

I want to handle the Ctrl+C signal (SIGINT) sent from the terminal in a Go program so I can run some cleanup logic before the program exits. For example, I would like to print partial run totals when the user interrupts the program.

How can I capture SIGINT and perform cleanup in a way that feels similar to defer?

Short Answer

By the end of this page, you will understand how Go handles OS signals, how to listen for SIGINT when the user presses Ctrl+C, and how to run cleanup code before exiting. You will also see how this differs from normal defer behavior and how developers structure graceful shutdown logic in real Go programs.

Concept

In Go, defer runs when the surrounding function returns normally. But signals like SIGINT come from the operating system, not from your code calling return. When the user presses Ctrl+C, the process receives an interrupt signal, and unless you handle it, the program usually stops immediately.

To respond to Ctrl+C, Go programs commonly use the os/signal package. This lets your program subscribe to signals such as:

  • os.Interrupt for Ctrl+C
  • syscall.SIGTERM for termination requests from the OS or container platforms

Once a signal is received, your code can:

  • print current totals
  • close files or network connections
  • stop background goroutines
  • flush buffered logs
  • exit cleanly

This matters because real programs often need a graceful shutdown instead of an abrupt stop. If a program exits instantly, it may lose progress, leave resources open, or fail to report useful final state.

A key idea is this:

  • defer is for cleanup when your function returns
  • signal handling is for cleanup when the operating system interrupts your process

You can combine both. A common pattern is:

  1. Wait for a signal.
  2. Trigger shutdown logic.
  3. Return from main or cancel work.
  4. Let deferred cleanup run as functions return.

Mental Model

Think of defer as cleaning up when you decide to leave a room.

Signal handling is different: someone from outside the room knocks on the door and says, you need to wrap up now.

Your program can either:

  • ignore the interruption and be forced out, or
  • respond politely by finishing important tasks first

So Ctrl+C handling is like having an emergency closing checklist:

  • save the current totals
  • turn off machines
  • lock the door
  • then leave

defer is still useful, but the signal handler is what starts the shutdown process.

Take Quiz

Syntax and Examples

In Go, you usually capture Ctrl+C with os/signal.

package main

import (
    "fmt"
    "os"
    "os/signal"
)

func main() {
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, os.Interrupt)

    fmt.Println("Program running. Press Ctrl+C to stop.")

    <-sigChan

    fmt.Println("\nCaught Ctrl+C. Running cleanup...")
    fmt.Println("Partial totals: 42")
}

What this does

  • make(chan os.Signal, 1) creates a channel that can receive a signal.
  • signal.Notify(sigChan, os.Interrupt) tells Go to send SIGINT events to that channel.
  • <-sigChan blocks until the user presses Ctrl+C.
  • After the signal arrives, the cleanup code runs.

Using defer together with signal handling

A more realistic pattern is to trigger shutdown and then return normally.

package main

import (
    "fmt"
    "os"
    "os/signal"
)

func main() {
    defer fmt.Println("Deferred cleanup from main")

    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, os.Interrupt)
    defer signal.Stop(sigChan)

    fmt.Println("Running. Press Ctrl+C...")

    <-sigChan

    fmt.Println("Interrupted. Printing partial totals...")
    fmt.Println("Total processed: 42")
}

Because main continues after receiving the signal and then returns normally, the deferred calls run.

A loop with running totals

package main

import (
    "fmt"
    "os"
    "os/signal"
    "time"
)

func main() {
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, os.Interrupt)
    defer signal.Stop(sigChan)

    total := 0
    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop()

    for {
        select {
        case <-ticker.C:
            total += 10
            fmt.Println("Working... total:", total)
        case <-sigChan:
            fmt.Println("\nCaught Ctrl+C")
            fmt.Println("Partial total:", total)
            return
        }
    }
}

This is a common beginner-friendly model for graceful shutdown in long-running programs.

Step by Step Execution

Consider this example:

package main

import (
    "fmt"
    "os"
    "os/signal"
    "time"
)

func main() {
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, os.Interrupt)
    defer signal.Stop(sigChan)

    total := 0

    for {
        select {
        case <-time.After(1 * time.Second):
            total++
            fmt.Println("Processed:", total)
        case <-sigChan:
            fmt.Println("Interrupted")
            fmt.Println("Final partial total:", total)
            return
        }
    }
}

Step-by-step

  1. sigChan is created to receive OS signals.
  2. signal.Notify(sigChan, os.Interrupt) registers interest in Ctrl+C.
  3. total starts at 0.
  4. The for loop runs forever.
  5. Inside select, the program waits for one of two things:
    • one second to pass
    • a signal to arrive
  6. If one second passes first:
    • total is increased by 1
    • the current count is printed
  7. If the user presses Ctrl+C:
    • sigChan receives os.Interrupt
    • the signal case runs
    • the program prints the partial total
    • return exits main
  8. Because main returns normally, deferred calls such as signal.Stop(sigChan) will run.

Example run

If the user waits about 3 seconds and then presses Ctrl+C, the output may look like this:

Processed: 1
Processed: 2
Processed: 3
Interrupted
Final partial total: 3

This is the core idea behind graceful interruption.

Real World Use Cases

Handling SIGINT is useful in many kinds of Go programs.

Command-line tools

A CLI that processes many files can print progress before stopping:

  • files completed
  • files skipped
  • errors seen so far

Data processing scripts

A long-running script can save a checkpoint before exiting so it can resume later.

Web servers

Servers often listen for shutdown signals and then:

  • stop accepting new requests
  • finish in-flight requests
  • close database connections

Background workers

Queue consumers and schedulers use signal handling to avoid losing work halfway through a job.

Monitoring or metrics tools

A tool collecting stats can print a final summary when interrupted manually from the terminal.

Take Quiz

Real Codebase Usage

In real projects, signal handling is usually part of a broader shutdown design.

Common pattern: signal -> cancel -> cleanup

Developers often use:

  • os/signal to detect shutdown
  • context.WithCancel or signal.NotifyContext to notify goroutines
  • defer to close resources in the functions that own them

Example shape:

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()

// start workers with ctx
// when Ctrl+C happens, ctx.Done() is closed

This scales better than putting all cleanup inside one signal block.

Guarded shutdown

Real code often ensures cleanup happens once:

  • close channels only once
  • flush logs once
  • avoid double shutdown calls

Early return pattern

When a signal arrives, many programs:

  • log the reason
  • print totals or metrics
  • return from main

This is simpler and safer than calling os.Exit, because os.Exit does not run deferred functions.

Validation and reporting

Shutdown handlers commonly report:

  • processed item count
  • elapsed time
  • last checkpoint
  • error totals

That is exactly the kind of partial run total described in the question.

Common Mistakes

1. Using os.Exit() and expecting deferred functions to run

Broken example:

defer fmt.Println("cleanup")
os.Exit(1)

Problem:

  • os.Exit() stops the program immediately.
  • Deferred calls do not run.

Avoid it by:

  • returning normally from main when possible
  • doing explicit cleanup before calling os.Exit

2. Forgetting to register for signals

Broken example:

sigChan := make(chan os.Signal, 1)
<-sigChan

Problem:

  • nothing will ever be sent to the channel unless you call signal.Notify

Correct version:

signal.Notify(sigChan, os.Interrupt)

3. Blocking forever in a way that never checks for signals

Broken idea:

  • running a tight loop or long blocking call with no shutdown path

Avoid it by:

  • using select
  • passing contexts to goroutines
  • checking ctx.Done() or a signal channel

4. Doing too much work directly in signal handling logic

If cleanup is large and complicated, keep the signal case simple:

  • record that shutdown started
  • cancel work
  • let normal code paths finish cleanup

This makes the program easier to reason about.

5. Not stopping signal notifications when done

You can clean up registration with:

defer signal.Stop(sigChan)

This is especially useful in larger programs and tests.

6. Assuming Ctrl+C means the same thing everywhere

Ctrl+C typically maps to SIGINT in terminal-based environments, but signal behavior can vary by platform and runtime environment. In Go, os.Interrupt is the portable choice for basic interrupt handling.

Comparisons

ConceptWhat it doesDeferred functions run?Best use
deferSchedules cleanup when a function returnsYesClosing files, unlocking mutexes, stopping timers
SIGINT handlingReacts to Ctrl+C from the terminalYes, if you return normallyGraceful shutdown and partial summaries
os.Exit()Terminates immediately with exit codeNoFatal exits after explicit cleanup
panicStops normal flow and begins stack unwindingYes, unless process is killedUnexpected program errors

defer vs signal handling

  • defer is local to a function.
  • signal handling is global process behavior.
  • signal handling often triggers a normal return so defer can do the rest.

os.Interrupt vs syscall.SIGTERM

SignalTypical sourceCommon use
os.InterruptUser presses Ctrl+CInteractive terminal shutdown
syscall.SIGTERMOS, service manager, container runtimeGraceful service shutdown

In production services, programs often handle both.

Take Quiz

Cheat Sheet

import (
    "os"
    "os/signal"
)

Basic pattern

sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt)
defer signal.Stop(sigChan)

<-sigChan
// cleanup here

Return normally if you want defer to run

defer cleanup()
return // deferred calls run

os.Exit() skips deferred calls

defer cleanup()
os.Exit(1) // cleanup does NOT run

Common graceful shutdown loop

for {
    select {
    case <-work:
        // do work
    case <-sigChan:
        // print totals / cleanup
        return
    }
}

Good practices

  • Use os.Interrupt for Ctrl+C
  • Use signal.Stop when finished
  • Keep signal handling simple
  • Prefer returning from main over calling os.Exit
  • Use contexts for larger apps

Key rule

defer does not automatically run just because a signal happened. Your code must handle the signal and return normally if you want deferred cleanup to execute.

FAQ

Can Go catch Ctrl+C from the terminal?

Yes. Use the os/signal package and listen for os.Interrupt.

Does defer automatically run when I press Ctrl+C?

Not by itself. You need to handle the signal and let the function return normally.

Should I use os.Exit() in a signal handler?

Usually no, because os.Exit() skips deferred cleanup. Prefer returning from main after cleanup.

How do I print partial totals before the program exits?

Store the running totals in variables, then print them when your signal channel receives os.Interrupt.

What is the difference between SIGINT and SIGTERM?

SIGINT usually comes from Ctrl+C. SIGTERM is commonly sent by the OS, containers, or process managers to request shutdown.

Can I handle more than one signal?

Yes. You can pass multiple signals to signal.Notify, such as os.Interrupt and syscall.SIGTERM.

What is the modern Go way to handle shutdown?

For larger applications, signal.NotifyContext is often a clean approach because it integrates signal handling with context cancellation.

Take Quiz

Mini Project

Description

Build a small Go program that simulates a long-running task and prints a partial progress summary when the user presses Ctrl+C. This demonstrates graceful interruption, signal handling, and cleanup-friendly program structure.

Goal

Create a program that counts work items over time and cleanly reports its current total when interrupted.

Requirements

  1. Create a loop that simulates ongoing work.
  2. Track a running total in a variable.
  3. Listen for Ctrl+C using os/signal.
  4. When interrupted, print the partial total and exit cleanly.
  5. Use at least one defer statement for normal cleanup.
Take Quiz

Keep learning

Related questions

View more