Skip to content
gohacks.dev
Concurrency

Handle SIGTERM and SIGINT in Go With NotifyContext

Catch SIGTERM and SIGINT in Go with signal.NotifyContext: what stop does, why a second Ctrl+C should kill you, SIGHUP reloads, containers and Windows.

8 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

Before Go 1.16, handling a signal meant a buffered channel, a signal.Notify call, a goroutine to read it, and some plumbing to turn that into cancellation for the rest of the program. signal.NotifyContext collapses that into one call, and for a service that is all most programs need. This article shows the call in action, the behaviors that surprise people (the second Ctrl+C, the buffered channel rule, signals that cannot be caught), and how to reload configuration on SIGHUP. It supports graceful shutdown of a Go HTTP service and sits in the concurrency patterns cluster. Everything ran on Go 1.25.6 on macOS.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

The call

NotifyContext takes a parent context and a list of signals, and returns a derived context plus a stop function. The returned context is cancelled when any listed signal arrives, when the parent is cancelled, or when stop is called, whichever comes first. The signature in the os/signal documentation is func NotifyContext(parent context.Context, signals ...os.Signal) (ctx context.Context, stop context.CancelFunc).

This program registers for SIGTERM and SIGINT, then sends itself a SIGTERM, which is what kill <pid>, docker stop and a Kubernetes pod deletion all do from outside:

main.goGo
package main

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

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	go func() {
		time.Sleep(100 * time.Millisecond)
		fmt.Println("sending SIGTERM to myself")
		syscall.Kill(os.Getpid(), syscall.SIGTERM)
	}()

	fmt.Println("waiting for a signal")
	<-ctx.Done()
	fmt.Println("context done:", ctx.Err())
}
Text
waiting for a signal
sending SIGTERM to myself
context done: context canceled

The context ends with context canceled, the same error any cancellation produces, so everything downstream (HTTP servers, database calls, worker loops) already knows what to do with it. That is the point of the function: a signal is no longer a special case with its own channel, it is a context you pass around. If you want the cause rather than just the error, remember that context.Cause exists for contexts you cancel yourself; a signal context reports plain cancellation.

os.Interrupt is SIGINT on every platform Go supports, which is why it is the portable half of the pair. syscall.SIGTERM exists on Unix and is defined on Windows too, but a Windows process never receives it from the system, so on Windows the only signals that fire are Ctrl+C and Ctrl+Break. Code that must run on both lists the two together and does not worry about it.

Why stop matters, and the second Ctrl+C

Registering for a signal changes its default behavior. Without a handler SIGINT kills the process; with one, the signal is delivered to your context and the process keeps running. That is what you want for the first Ctrl+C. It is a trap for the second one: if shutdown hangs, say a handler is stuck, the developer presses Ctrl+C again and nothing happens, because the registration is still active and the signal is absorbed again.

stop fixes this. The documentation says that after stop is called, the registration is released and the default behavior returns, so subsequent signals are not intercepted. The pattern is to call stop() explicitly as soon as the first signal fires, then proceed with the drain:

main.goGo
package main

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

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	go func() {
		time.Sleep(50 * time.Millisecond)
		syscall.Kill(os.Getpid(), syscall.SIGTERM) // the first signal: graceful
		time.Sleep(200 * time.Millisecond)
		syscall.Kill(os.Getpid(), syscall.SIGTERM) // the impatient second one
	}()

	<-ctx.Done()
	fmt.Println("first signal received, draining")
	stop() // from here on a second signal uses the default action

	time.Sleep(2 * time.Second)   // a drain that never finishes in this demo
	fmt.Println("drain finished") // never printed
}
Text
first signal received, draining
signal: terminated

The program exits long before its two-second "drain" ends, killed by the second SIGTERM, and go run reports signal: terminated. Without the explicit stop() the second signal would have been swallowed and the process would have limped on until the drain finished or the orchestrator lost patience and sent SIGKILL. A deferred stop alone does not do this, because deferred calls run when main returns, which is after the drain.

SIGKILL cannot be caught

SIGKILL and SIGSTOP are the two signals the kernel never delivers to a handler. The Go documentation for the package states it plainly: they cannot be caught or ignored. The practical consequence is the shape of every orchestrated shutdown: the platform sends SIGTERM, waits a grace period, then sends SIGKILL. Kubernetes defaults that period to 30 seconds, Docker's stop to 10. Your drain timeout has to be shorter than the grace period, or the part of the drain after the cut is theatre. There is no clean-up you can do inside the signal handler for a SIGKILL, so anything that must be durable has to be durable before you are killed (a write-ahead log, a transaction, an idempotent job).

Reloading configuration with SIGHUP

Not every signal means "stop". SIGHUP by convention means "re-read your configuration", and daemons like nginx and Prometheus use it that way. NotifyContext is built for one-shot cancellation, so a signal that should fire repeatedly needs the lower-level signal.Notify with a channel. The channel must be buffered, and go vet has a check for an unbuffered one. The documentation explains why: the package does not block sending to a channel, so if the receiver is not ready at that instant and the channel has no space, the signal is dropped. A buffer of one is enough for "something happened, reload"; a reload that runs while another signal arrives collapses into the next.

main.goGo
package main

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

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	reload := make(chan os.Signal, 1) // buffered: Notify never blocks on delivery
	signal.Notify(reload, syscall.SIGHUP)
	defer signal.Stop(reload)

	go func() {
		for i := 0; i < 2; i++ {
			time.Sleep(80 * time.Millisecond)
			syscall.Kill(os.Getpid(), syscall.SIGHUP)
		}
		time.Sleep(80 * time.Millisecond)
		syscall.Kill(os.Getpid(), syscall.SIGTERM)
	}()

	reloads := 0
	for {
		select {
		case <-reload:
			reloads++
			fmt.Println("reloading configuration, count", reloads)
		case <-ctx.Done():
			fmt.Println("shutting down after", reloads, "reloads")
			return
		}
	}
}
Text
reloading configuration, count 1
reloading configuration, count 2
shutting down after 2 reloads

Two reloads happened and then the SIGTERM ended the loop. Notice that the program registers SIGHUP separately from the context, so the two kinds of signal do not interfere: HUP never cancels the context, and TERM never lands in the reload channel. If you do not register for SIGHUP at all, the default action for it is to terminate the process, which is why a program that ignores it can die when its terminal closes. The signal.Ignore function exists for the opposite intent, and nohup works by ignoring HUP before it starts your command.

When a reload rebuilds shared state, the usual caution applies: build the new configuration completely, then swap it in with a single atomic pointer store so readers never see half of it (typed atomics are the right tool).

In a container, make sure the signal arrives

The most common reason graceful shutdown "does not work in Docker" has nothing to do with Go. The signal goes to PID 1 of the container, and if PID 1 is a shell, the shell does not forward it. A Dockerfile with CMD ./server in shell form runs /bin/sh -c ./server, the shell is PID 1, your server is its child, and docker stop sends SIGTERM to a shell that ignores it. Ten seconds later Docker sends SIGKILL and the drain never ran. The Dockerfile reference documents the exec form, CMD ["./server"], which runs the binary directly as PID 1 so it receives the signal. The same applies to ENTRYPOINT, and to wrapper scripts, which should end with exec ./server so the shell replaces itself.

Running as PID 1 has one more difference: on Linux the kernel does not apply default signal actions to PID 1, so a PID 1 process with no handler ignores SIGTERM entirely. A Go binary that registers NotifyContext is fine, but one that does not will appear to ignore docker stop. Docker's --init flag, or an init such as tini, puts a small reaper in front of your process for this reason and for zombie reaping.

The older channel form

You will meet code that predates NotifyContext:

Go
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() {
	<-sigs
	cancel()
}()

It works, and is just more code: a channel, a goroutine and a manual cancel. The channel form is still the right tool when you need to keep receiving the same signal, as in the SIGHUP example. For "stop when told to", prefer the context form, and pass the context down so every layer sees the same cancellation.

Checklist

Register the signals once, near the top of main. Put defer stop() right after the call, and call stop() early too, as soon as <-ctx.Done() fires. Use a fresh context with its own deadline for the drain, because the signal context is already cancelled and anything derived from it ends instantly. Buffer any channel you give to signal.Notify. Use the exec form in Dockerfiles. And keep the drain shorter than the platform's grace period. The rest of the shutdown sequence, with servers, workers and the database, is in the graceful shutdown walkthrough, and the cancellation model underneath it is explained in the Go blog's context article.

Also inDevOps
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.