This guide explains how to terminate a process from Go: by numeric PID, through the *os.Process attached to a command you started with os/exec, or on a deadline using exec.CommandContext. It contrasts cooperative stops with forced kills, clarifies that Process.Kill does not wait for exit and does not stop unrelated child processes by itself, and ends with portability notes and a compact cheat sheet. For long-running workers and health checks, see monitor a background process in Go.
Tested on: Go go1.24.4 linux/amd64; kernel 6.14.0-37-generic.
What does it mean to kill a process in Go?
“Kill” in everyday language mixes two ideas: asking a program to exit (often with SIGTERM on Unix) and forcing it to stop immediately (typically SIGKILL, the same semantics as kill -9). Go exposes both paths through os.Process: Signal can deliver catchable signals, while Kill requests a hard stop on Unix. Neither call waits for the OS to finish tearing the process down; for subprocesses you start with os/exec, you still coordinate shutdown with Cmd.Wait.
Kill a process by PID in Go
The usual flow to kill a process by PID in Go is: obtain an os.Process for the integer PID, then call Signal or Kill. os.FindProcess returns a handle for that PID. On Unix, FindProcess always succeeds for any positive PID—the kernel is not queried until you call Signal or Kill, so “find” does not prove the process exists.
package main
import (
"fmt"
"os"
)
func main() {
p, err := os.FindProcess(999999999)
if err != nil {
panic(err)
}
if err := p.Kill(); err != nil {
fmt.Println(err)
}
}os: process already finishedOn Linux you can also send a signal with syscall.Kill, which surfaces POSIX-style errors such as “no such process” for invalid PIDs:
package main
import (
"fmt"
"syscall"
)
func main() {
if err := syscall.Kill(999999999, syscall.SIGKILL); err != nil {
fmt.Println(err)
}
}no such processYou need permission to signal another user’s processes. Prefer os/exec and cmd.Process for children your program starts so you are not guessing PIDs.
Process.Kill vs Process.Signal
Process.Signal(sig)delivers a specific signal. On Unix,syscall.SIGTERMis the usual “please shut down” signal (similar to the defaultkillin the shell). The target may ignore or handle it.Process.Kill()requests an uncatchable stop on Unix (same practical effect asSIGKILL/kill -9): the process cannot handle or defer it.
Both return an error from the signaling operation; neither replaces Wait for a subprocess you started.
This program starts two sleep children so you can compare outcomes on Linux: the first is stopped with Signal(SIGTERM) and usually ends with signal: terminated; the second is stopped with Kill and ends with signal: killed.
package main
import (
"fmt"
"os/exec"
"syscall"
"time"
)
func main() {
cmd1 := exec.Command("sleep", "60")
if err := cmd1.Start(); err != nil {
panic(err)
}
if err := cmd1.Process.Signal(syscall.SIGTERM); err != nil {
fmt.Println("signal err:", err)
}
fmt.Println("SIGTERM path:", cmd1.Wait())
cmd2 := exec.Command("sleep", "60")
if err := cmd2.Start(); err != nil {
panic(err)
}
time.Sleep(50 * time.Millisecond)
if err := cmd2.Process.Kill(); err != nil {
fmt.Println("kill err:", err)
}
fmt.Println("Kill path:", cmd2.Wait())
}You should see two lines beginning with SIGTERM path: and Kill path: reflecting those exit reasons. The next section walks the same APIs for a single long-running command.
Kill a process started with exec.Command
When you start work with exec.Command and Start, cmd.Process is non-nil and holds the child PID. That is the usual way to kill an exec subprocess from the same Go program: call cmd.Process.Signal or cmd.Process.Kill, then cmd.Wait (possibly in a goroutine) so the child is reaped and zombies do not accumulate.
SIGTERM is what many programs expect for a graceful shutdown:
package main
import (
"fmt"
"os/exec"
"syscall"
"time"
)
func main() {
cmd := exec.Command("sleep", "100")
if err := cmd.Start(); err != nil {
panic(err)
}
fmt.Println("PID:", cmd.Process.Pid)
go func() {
time.Sleep(200 * time.Millisecond)
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
fmt.Println("signal:", err)
}
}()
fmt.Println("wait:", cmd.Wait())
}On Linux, sleep exits on SIGTERM, so Wait typically reports an error wrapping signal: terminated.
Hard stop with Process.Kill (Unix: uncatchable kill):
package main
import (
"fmt"
"os/exec"
"time"
)
func main() {
cmd := exec.Command("sleep", "100")
if err := cmd.Start(); err != nil {
panic(err)
}
fmt.Println("PID:", cmd.Process.Pid)
time.Sleep(200 * time.Millisecond)
if err := cmd.Process.Kill(); err != nil {
fmt.Println("kill:", err)
}
fmt.Println("wait:", cmd.Wait())
}After Kill, Wait usually reports a wrapped error such as signal: killed. Prefer SIGTERM first when the child might flush data; reserve Kill for hung or runaway processes.
Kill a process after timeout with exec.CommandContext
For timeouts, wrap the command in a context.Context with a deadline or cancel function. When the context ends, exec.CommandContext stops the child (interrupt, then kill as needed). You still observe the final state through Wait or Run.
package main
import (
"context"
"fmt"
"os/exec"
"time"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
defer cancel()
cmd := exec.CommandContext(ctx, "sleep", "10")
fmt.Println(cmd.Run())
}Typical output wraps signal: killed once the deadline passes. Use this pattern for HTTP handlers, jobs, and tests that must not hang forever.
Graceful termination with SIGTERM
A safer sequence when you need to kill a process in Go and you control the child: send SIGTERM, give the program a short window to exit (wait on Wait in a goroutine with a select and time.After), and only then call Kill if it is still running. Many daemons close listeners and flush on SIGTERM; Kill skips that opportunity.
package main
import (
"fmt"
"os/exec"
"syscall"
"time"
)
func main() {
cmd := exec.Command("sleep", "100")
if err := cmd.Start(); err != nil {
panic(err)
}
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
fmt.Println("SIGTERM:", err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
fmt.Println("exited within grace:", err)
case <-time.After(3 * time.Second):
fmt.Println("grace expired, forcing")
_ = cmd.Process.Kill()
fmt.Println("after force:", <-done)
}
}On Linux, sleep exits quickly on SIGTERM, so you usually hit the first select branch and print exited within grace: with signal: terminated. If the child ignored SIGTERM, the timer branch would run Kill and then print the result of the second Wait.
Force kill with SIGKILL
SIGKILL cannot be caught or ignored: the kernel tears the process down. In Go, Process.Kill maps to that behavior on Unix; syscall.Kill(pid, syscall.SIGKILL) is the same idea when you only have a PID. Use force kill when the target ignores SIGTERM, is stuck in uninterruptible state you cannot fix otherwise, or policy says “stop now.” Avoid defaulting to force kill for every shutdown—it prevents clean teardown.
Here the parent starts sleep, waits briefly, then sends SIGKILL by PID with syscall.Kill (equivalent in effect to calling cmd.Process.Kill on Unix for this child):
package main
import (
"fmt"
"os/exec"
"syscall"
"time"
)
func main() {
cmd := exec.Command("sleep", "100")
if err := cmd.Start(); err != nil {
panic(err)
}
pid := cmd.Process.Pid
time.Sleep(50 * time.Millisecond)
if err := syscall.Kill(pid, syscall.SIGKILL); err != nil {
fmt.Println("syscall.Kill:", err)
}
fmt.Println(cmd.Wait())
}You should see a single Wait result wrapping signal: killed.
Kill child processes and process groups
Process.Kill only affects the process you signal, not processes it started. Killing a parent shell does not reliably kill background grandchildren; children may be reparented (for example to PID 1) when the parent exits.
Rob Unix-oriented options:
- Start the child with
syscall.SysProcAttr.Setpgid: trueso it runs in its own process group, then signal-pidwithsyscall.Killto hit the whole group on Linux. - Track each child PID yourself and signal them in order.
- Delegate lifecycle to systemd, containers, or a supervisor when the tree is large or cross-user.
Example: new process group and group kill (Linux):
package main
import (
"fmt"
"os/exec"
"syscall"
"time"
)
func main() {
cmd := exec.Command("sleep", "100")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := cmd.Start(); err != nil {
panic(err)
}
pid := cmd.Process.Pid
time.Sleep(100 * time.Millisecond)
if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {
fmt.Println("kill group:", err)
}
fmt.Println("wait:", cmd.Wait())
}Save as a local file and run with go run; this pattern is Unix-specific (negative PID process groups).
Cross-platform notes: Linux, macOS, and Windows
Unix systems share signals (SIGTERM, SIGKILL, and so on). Windows does not implement POSIX signals the same way: Process.Signal supports a smaller set, while Process.Kill maps to TerminateProcess. Code that passes syscall.SIGUSR1 or assumes every Unix signal exists will misbehave on Windows unless you use build tags or separate files (foo_unix.go / foo_windows.go). For portable tools, prefer exec.CommandContext plus documented behavior on each GOOS you ship.
Common errors and mistakes
| Symptom | Likely cause | What to try |
|---|---|---|
no such process / process already finished |
PID exited or never existed | Confirm PID; remember Unix FindProcess does not validate liveness |
operation not permitted |
Different user or missing capability | Run with appropriate privileges or only signal your own children |
| Orphan subprocesses after “kill” | You stopped the parent only | Process groups, explicit child PIDs, or a supervisor |
| Zombie children | Start without Wait |
Always reap with Wait (directly or in a goroutine) |
| Hung shutdown | Only used Kill immediately |
Try SIGTERM and a short grace window first |
Windows build breaks on syscall.SIGTERM usage in non-portable code |
OS-specific signals | Split by build tag or use exec.CommandContext |
Best practices
- Prefer graceful stop (
SIGTERMor equivalent) beforeKill, with a bounded wait then force. - Use
exec.CommandContextfor timeouts and cancellation instead of ad-hoc timers that forget to kill. - After
Start, always arrange aWaitso errors and exit codes are observed and zombies do not accumulate. - Never kill unrelated PIDs; avoid broad name-based tools (
pkill,killall) unless you fully control the machine and pattern. - Treat process groups and signal numbers as advanced and OS-specific; document assumptions (
linux/amd64, internal service, and so on).
Go kill process cheat sheet
| Goal | Approach |
|---|---|
| Stop by numeric PID | os.FindProcess → Signal / Kill, or syscall.Kill on Unix |
| Stop a child you started | cmd.Process.Signal / cmd.Process.Kill after Start |
| Timeout / cancel | exec.CommandContext + Run/Wait |
| Cooperative shutdown | Signal(SIGTERM) (Unix), then wait, then Kill if needed |
| Force stop | Process.Kill or syscall.Kill(..., SIGKILL) on Unix |
| Whole subtree on Linux | Setpgid + syscall.Kill(-pid, ...) |
| Windows portability | Prefer Kill / CommandContext; avoid raw Unix-only signals without tags |
Summary
Killing a process by PID in Go usually means using os.Process (from os.FindProcess or cmd.Process) and choosing Signal for cooperative SIGTERM-style shutdown versus Kill for a Unix force stop that the target cannot handle. exec.CommandContext ties subprocess lifetime to context cancellation and deadlines—the usual pattern when a command must stop after a timeout or when upstream work is canceled. Process.Kill does not wait for exit and does not stop unrelated child processes; for trees you need process groups, explicit child tracking, or external supervision. Match signals and syscall.SysProcAttr to the GOOS you support, reap children with Wait, and reserve SIGKILL-class stops for cases where grace has failed or policy demands an immediate halt.
References
os/execpackageos.Processos.FindProcesssyscall.Kill(Unix)contextpackage- Monitor background processes in Go

