How Go Handles Signals and Interrupts
You press Ctrl+C to stop a long-running script. The program vanishes instantly. The database transaction it was writing gets corrupted. The client waiting for a response receives a broken pipe error. You need the program to notice the interrupt, finish what it's doing, and shut down cleanly. That's where signals come in.
Go doesn't handle these interrupts automatically in a way that lets you run cleanup code. By default, the runtime catches the signal and terminates the process. To do something useful, you have to tell Go to catch the signal and send it to a channel. Channels are the bridge between the OS interrupt and your Go code.
Signals are OS notifications, not Go events
Operating systems use signals to communicate with processes. A signal is a notification that something happened. SIGINT means the user pressed Ctrl+C. SIGTERM means another process asked this one to stop. SIGKILL means the OS is killing the process immediately, and no handler can stop it.
Go runs on top of the OS. When a signal arrives, the OS delivers it to the Go runtime. The runtime has a built-in handler that usually just exits. The os/signal package lets you override that behavior. You register a channel, and the runtime sends the signal value into that channel instead of dying. This moves the signal handling into Go's concurrency model. You can process the signal in a goroutine, coordinate with other goroutines, and run cleanup logic safely.
Signals are fire-and-forget from the OS. The OS doesn't wait for you to handle them. If your channel is full, the signal is dropped. Buffer the channel to avoid losing signals.
Minimal signal handler
This example sets up a listener for Ctrl+C and the termination signal. It blocks until a signal arrives, then prints a message and exits.
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
)
// main starts the signal listener and waits for shutdown.
func main() {
// Create a buffered channel to hold the signal.
// Buffer size 1 prevents the goroutine from blocking if the signal arrives before we read.
sigs := make(chan os.Signal, 1)
// Notify registers the sigs channel to receive SIGINT and SIGTERM.
// syscall.SIGINT is Ctrl+C. syscall.SIGTERM is the default kill signal.
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
// Block until a signal arrives.
// This stops main from exiting immediately.
sig := <-sigs
fmt.Printf("Received signal: %v\n", sig)
}
What happens at runtime
When the program starts, signal.Notify registers the channel with the runtime. The runtime sets up a system-level handler. When you press Ctrl+C, the OS sends SIGINT. The runtime catches it and sends the signal value into the sigs channel. The <-sigs operation unblocks. Your code runs.
If you don't register a handler, the default behavior kicks in. The process dies immediately. No cleanup runs. No finalizers run. The process just stops.
The compiler won't catch signal bugs because they are runtime behaviors. The danger is the deadlock. If you create an unbuffered channel and the signal arrives before your receive statement, the runtime tries to send to the channel. Since no one is receiving, the send blocks. If your main goroutine is waiting for something else, the program hangs. The runtime eventually panics with fatal error: all goroutines are asleep - deadlock!. Buffer the channel to avoid this.
Signals are interrupts. Channels are the bridge. Buffer the channel.
Graceful shutdown with an HTTP server
Real programs need to shut down resources. An HTTP server should stop accepting new requests and wait for active requests to finish. A database connection should be closed. A file writer should flush.
This example starts an HTTP server, listens for signals, and shuts down gracefully. It uses a context with a timeout to bound the shutdown duration.
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
// startServer creates an HTTP server and starts listening in the background.
// It returns the server instance so the caller can shut it down later.
func startServer() *http.Server {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Simulate a slow request to demonstrate graceful shutdown.
time.Sleep(2 * time.Second)
fmt.Fprintln(w, "Hello")
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
}
// Start serving in a goroutine so main can listen for signals.
go func() {
// ListenAndServe blocks until the server is closed or an error occurs.
// http.ErrServerClosed is expected during graceful shutdown.
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Printf("Server error: %v\n", err)
}
}()
return srv
}
// main sets up the server, waits for a signal, and shuts down gracefully.
func main() {
srv := startServer()
fmt.Println("Server running on :8080")
// Buffered channel to catch the signal without blocking the goroutine.
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
// Wait for the signal.
<-quit
fmt.Println("Shutting down server...")
// Create a context with a timeout for the shutdown process.
// This prevents the shutdown from hanging forever if connections are stuck.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Shutdown stops the server and waits for active connections to finish.
// It respects the context deadline.
if err := srv.Shutdown(ctx); err != nil {
fmt.Printf("Server forced to shutdown: %v\n", err)
}
fmt.Println("Server exited properly")
}
The context.WithTimeout call creates a deadline. srv.Shutdown stops accepting new connections and waits for active ones. If the timeout expires before all connections finish, Shutdown returns an error and the server closes immediately. This ensures the process doesn't hang.
context.Context always goes as the first parameter. The variable name is conventionally ctx. Functions that take a context should respect cancellation and deadlines. defer cancel releases resources immediately.
Graceful shutdown is a promise to your users. Keep it.
The double Ctrl+C problem
Users expect Ctrl+C to stop the program. If you catch the signal and start a graceful shutdown, the program might take a few seconds to finish. Impatient users press Ctrl+C again.
If your channel has a buffer of 1, the second signal is dropped. The user thinks the program is broken. You can handle this by listening for a second signal and forcing an exit.
// forceExit handles a second signal by exiting immediately.
func forceExit() {
fmt.Println("Force exiting...")
os.Exit(1)
}
// main demonstrates handling a second signal to force exit.
func main() {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
// Wait for the first signal.
<-quit
fmt.Println("Shutting down...")
// Start a goroutine to watch for a second signal.
go func() {
<-quit
forceExit()
}()
// Simulate shutdown work.
time.Sleep(3 * time.Second)
fmt.Println("Shutdown complete")
}
This pattern uses a goroutine to listen for the second signal. If the second signal arrives, forceExit calls os.Exit. os.Exit terminates the process immediately. Deferred functions do not run. Use this only when you must terminate instantly.
Context is the deadline. Signals are the trigger.
Pitfalls and runtime behavior
Unbuffered channels cause deadlocks. If you use make(chan os.Signal), the send blocks until someone receives. If the signal arrives before the receive, the runtime goroutine blocks. If main is waiting for something else, the program hangs. Always buffer the signal channel by at least 1.
SIGKILL cannot be caught. The OS kills the process instantly. No handler runs. This is by design for system safety. You can't prevent SIGKILL. Design your program to handle SIGTERM and SIGINT gracefully so SIGKILL is only needed in emergencies.
signal.Ignore lets you ignore signals. signal.Ignore(syscall.SIGPIPE) prevents the process from crashing when writing to a broken pipe. Go's net package handles SIGPIPE internally by setting the SO_NOSIGPIPE flag on sockets. You usually don't need to ignore SIGPIPE. If you see SIGPIPE crashes, you're likely using raw sockets or a third-party library that doesn't follow Go conventions.
signal.Reset stops listening for signals. You rarely need this in long-running services. If you reset, the default behavior returns. The process dies on the next signal.
The worst goroutine bug is the one that never logs. Log your shutdown steps so you can debug hangs.
Decision matrix
Use signal.Notify with a buffered channel when you need to catch SIGINT or SIGTERM to perform cleanup like closing database connections or finishing HTTP requests.
Use context.WithTimeout combined with signal handling when you need to bound the shutdown duration so the process doesn't hang on stuck connections.
Use signal.Ignore when you must suppress a signal that would otherwise crash the process, though Go's standard library usually handles this for you.
Use os.Exit only in rare cases where you must terminate immediately without running deferred functions, such as in a wrapper script that launched a child process.
Use plain sequential code without signal handling when the program runs to completion quickly and has no resources to clean up.