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.InterruptforCtrl+Csyscall.SIGTERMfor 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:
deferis 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:
- Wait for a signal.
- Trigger shutdown logic.
- Return from
mainor cancel work. - 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.
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 sendSIGINTevents to that channel.<-sigChanblocks until the user pressesCtrl+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
sigChanis created to receive OS signals.signal.Notify(sigChan, os.Interrupt)registers interest inCtrl+C.totalstarts at0.- The
forloop runs forever. - Inside
select, the program waits for one of two things:- one second to pass
- a signal to arrive
- If one second passes first:
totalis increased by1- the current count is printed
- If the user presses
Ctrl+C:sigChanreceivesos.Interrupt- the signal case runs
- the program prints the partial total
returnexitsmain
- Because
mainreturns normally, deferred calls such assignal.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.
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/signalto detect shutdowncontext.WithCancelorsignal.NotifyContextto notify goroutinesdeferto 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
mainwhen 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
| Concept | What it does | Deferred functions run? | Best use |
|---|---|---|---|
defer | Schedules cleanup when a function returns | Yes | Closing files, unlocking mutexes, stopping timers |
SIGINT handling | Reacts to Ctrl+C from the terminal | Yes, if you return normally | Graceful shutdown and partial summaries |
os.Exit() | Terminates immediately with exit code | No | Fatal exits after explicit cleanup |
panic | Stops normal flow and begins stack unwinding | Yes, unless process is killed | Unexpected program errors |
defer vs signal handling
deferis local to a function.- signal handling is global process behavior.
- signal handling often triggers a normal return so
defercan do the rest.
os.Interrupt vs syscall.SIGTERM
| Signal | Typical source | Common use |
|---|---|---|
os.Interrupt | User presses Ctrl+C | Interactive terminal shutdown |
syscall.SIGTERM | OS, service manager, container runtime | Graceful service shutdown |
In production services, programs often handle both.
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.InterruptforCtrl+C - Use
signal.Stopwhen finished - Keep signal handling simple
- Prefer returning from
mainover callingos.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.
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
- Create a loop that simulates ongoing work.
- Track a running total in a variable.
- Listen for
Ctrl+Cusingos/signal. - When interrupted, print the partial total and exit cleanly.
- Use at least one
deferstatement for normal cleanup.
Keep learning
Related questions
Are Go Maps Passed by Value or Reference?
Learn Go map parameter and return semantics: maps are passed by value, while copies share underlying data. Know when map pointers are useful.
Authentication for Go REST APIs: Sessions, Tokens, and Middleware
Learn practical authentication patterns for Go REST APIs, including password hashing, server sessions, bearer tokens, middleware, and security rules.
Automatic Build Versioning in Go: Embed Incrementing Build Numbers
Learn how to add automatic build versioning in Go using linker flags, build metadata, CI counters, and Git-based version values.