How to Work with OS Signals in Go (SIGTERM, SIGINT)

Handle OS signals in Go by creating a buffered channel, using `signal.Notify` to listen for specific signals like `SIGINT` and `SIGTERM`, and then blocking on that channel in a goroutine to trigger your cleanup logic.

When Ctrl+C drops your connections

You hit Ctrl+C to stop your Go server. The terminal returns to the prompt immediately. Your database transaction rolls back halfway. A client gets a 502 error because the connection dropped mid-response. The process died, but the work didn't finish cleanly. This happens because the default signal handler terminates the process instantly. You need to catch the signal, pause, and shut down gracefully.

Signals are interrupts, not function calls

OS signals are interrupts sent by the operating system to a process. Think of a signal like a tap on the shoulder. Someone taps you and says "Stop what you're doing." The OS sends SIGINT when you press Ctrl+C. It sends SIGTERM when a tool like kill or Kubernetes asks the process to exit. The default behavior is to drop everything and vanish.

Go lets you intercept that tap. You can say "I hear you, let me finish this sentence first," then exit. The os/signal package provides the bridge between the OS interrupt and your Go code. Signals are asynchronous. They can arrive at any time, even while your code is blocked on I/O. The runtime delivers them to a registered channel so you can handle them in a structured way.

The minimal handler

Here's the simplest signal handler. It spawns a goroutine to listen for signals and blocks the main function until one arrives.

package main

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

func main() {
	// Buffered channel holds one signal to prevent blocking the notifier
	sigs := make(chan os.Signal, 1)
	// Register the channel for SIGINT and SIGTERM
	signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)

	// Block until a signal arrives
	sig := <-sigs
	fmt.Printf("Received signal: %s\n", sig)
}

Buffer the channel. The notifier goroutine will thank you.

How the runtime delivers signals

When the program starts, signal.Notify registers the channel with the runtime. The runtime sets up a system call to wait for signals. When you press Ctrl+C, the OS delivers SIGINT. The runtime places the signal value into the sigs channel. The main goroutine, blocked on <-sigs, wakes up and receives the value.

If the channel were unbuffered, the notifier goroutine inside signal.Notify could block trying to send if the receiver isn't ready. This can cause the program to hang or miss signals. The buffer of size one ensures the send succeeds immediately, and the receiver catches it later. The buffer size is one because you only care that a signal arrived, not how many times. A second signal during shutdown is redundant.

The syscall package defines constants like SIGINT and SIGTERM. If you forget to import syscall, the compiler rejects the program with undefined: syscall. If you pass an invalid signal constant, you get a runtime panic or silent failure depending on the platform. Stick to the standard constants.

Graceful shutdown with HTTP

Real apps need to close resources. An HTTP server stops accepting new connections and waits for active requests. Use context to coordinate the shutdown. The context carries the deadline so resources can bail out if cleanup takes too long.

// runServer starts the HTTP server and waits for a shutdown signal.
func runServer() {
	srv := &http.Server{Addr: ":8080"}

	// Run server in goroutine so main can block on signals
	go func() {
		if err := srv.ListenAndServe(); err != http.ErrServerClosed {
			log.Fatalf("server error: %v", err)
		}
	}()

	// Buffer size 1 ensures the signal send never blocks
	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit
}

The server runs in a background goroutine. This allows the main goroutine to block on the signal channel. When the signal arrives, the function returns. The caller then triggers the shutdown sequence.

// shutdownGracefully stops accepting connections and waits for active requests.
func shutdownGracefully(srv *http.Server) {
	// Context deadline forces exit if cleanup takes too long
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		log.Fatalf("shutdown failed: %v", err)
	}
}

srv.Shutdown stops Listen, closes idle connections, and waits for active handlers to return. If the context expires before all requests finish, Shutdown returns an error and the server stops waiting. The timeout prevents the application from hanging indefinitely if a request stalls.

Context is plumbing. Run it through every long-lived call site. Your shutdown function accepts a context so the caller can enforce a deadline. Functions that take a context should respect cancellation and deadlines. The convention is to pass ctx as the first parameter, named ctx.

Containers and the kill chain

When Kubernetes scales down a pod, it sends SIGTERM. The pod enters a terminating state. The load balancer stops sending traffic. The application has a window defined by terminationGracePeriodSeconds to finish work. If the app doesn't exit, Kubernetes sends SIGKILL.

Your signal handler must finish within that window. The default grace period is 30 seconds. You can adjust it in the pod spec, but your application should aim to shut down in under 10 seconds. Long-running requests should be cancelled or completed quickly. Database connections should be closed. Cache clients should flush.

Testing locally requires sending signals. kill -TERM <pid> mimics the container runtime. kill -INT <pid> mimics Ctrl+C. You can also use signal.Notify to test custom signals like SIGUSR1 for reloading config, though that's a different pattern. The signal handler pattern is the same: register, block, handle.

Pitfalls and compiler errors

Unbuffered channels cause deadlocks. If you create an unbuffered channel and the signal arrives before the receiver is ready, the notifier blocks. The program hangs. Always buffer the signal channel to one.

os.Exit skips deferred functions. If you call os.Exit in your signal handler, deferred cleanup code won't run. Use return or let the program exit naturally so defer runs. os.Exit is useful in child processes after exec, but not for graceful shutdown.

signal.Notify does not reset the default handler for SIGKILL. You cannot catch SIGKILL. The OS kills the process immediately. No Go code runs. No defer runs. No cleanup. This is why graceful shutdown matters. If you ignore SIGTERM, you get SIGKILL.

The if err != nil boilerplate is verbose by design. The community accepts it because it makes the unhappy path visible. In shutdown logic, you usually log and exit rather than return, since the process is terminating. log.Fatalf prints the error and calls os.Exit(1). This is standard for fatal errors in main.

SIGKILL is the nuclear option. You can't catch it. Design your app to handle SIGTERM properly so SIGKILL is never needed.

Decision matrix

Use signal.Notify with a buffered channel when you need to intercept termination signals for graceful shutdown. Use signal.Ignore when you explicitly want to discard a signal, though this is rare for termination signals. Use os.Exit when you need to terminate immediately without running deferred functions, such as in a child process after exec. Use a timeout context during shutdown to prevent the application from hanging if a resource fails to close. Use signal.Reset when you need to unregister a signal handler, though this is uncommon in application code.

Graceful shutdown protects your data. Crash recovery is for emergencies.

Where to go next