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.

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:
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())
}waiting for a signal
sending SIGTERM to myself
context done: context canceledThe 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:
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
}first signal received, draining
signal: terminatedThe 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.
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
}
}
}reloading configuration, count 1
reloading configuration, count 2
shutting down after 2 reloadsTwo 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:
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.


