Golang Kill Process by PID: Process.Kill, Signal, and CommandContext

Terminate processes from Go by PID or child PID: os.Process and os.FindProcess, Process.Kill vs Process.Signal (SIGTERM vs SIGKILL), stopping commands started with os/exec, timeouts with exec.CommandContext, graceful shutdown then force kill, Unix process groups for child trees, Windows vs Linux notes, and common mistakes.

Published

Updated

Read time 9 min read

Reviewed byDeepak Prasad

Golang Kill Process by PID: Process.Kill, Signal, and CommandContext

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.

go
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)
	}
}
Output
text
os: process already finished

On Linux you can also send a signal with syscall.Kill, which surfaces POSIX-style errors such as “no such process” for invalid PIDs:

go
package main

import (
	"fmt"
	"syscall"
)

func main() {
	if err := syscall.Kill(999999999, syscall.SIGKILL); err != nil {
		fmt.Println(err)
	}
}
Output
text
no such process

You 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.SIGTERM is the usual “please shut down” signal (similar to the default kill in the shell). The target may ignore or handle it.
  • Process.Kill() requests an uncatchable stop on Unix (same practical effect as SIGKILL / 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.

go
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())
}
Output

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:

go
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())
}
Output

On Linux, sleep exits on SIGTERM, so Wait typically reports an error wrapping signal: terminated.

Hard stop with Process.Kill (Unix: uncatchable kill):

go
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())
}
Output

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.

go
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())
}
Output

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.

go
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)
	}
}
Output

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):

go
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())
}
Output

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: true so it runs in its own process group, then signal -pid with syscall.Kill to 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):

go
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 (SIGTERM or equivalent) before Kill, with a bounded wait then force.
  • Use exec.CommandContext for timeouts and cancellation instead of ad-hoc timers that forget to kill.
  • After Start, always arrange a Wait so 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.FindProcessSignal / 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


Frequently Asked Questions

1. How do I kill a process by PID in Go?

Use os.FindProcess(pid) to obtain an *os.Process, then call Signal with syscall.SIGTERM for a cooperative stop or Kill for a force stop; on Linux you can alternatively use syscall.Kill(pid, sig). Errors often appear only on Signal or Kill, not on FindProcess, because on Unix FindProcess does not verify that the PID still exists.

2. What is the difference between Process.Kill and Process.Signal in Go?

Signal delivers a specific signal such as SIGTERM on Unix so the target can shut down cleanly if it handles that signal. Kill requests an uncatchable stop on Unix (like SIGKILL): the target cannot ignore it, and Kill does not wait for the process to exit—you still call Wait on commands you started with os/exec.

3. Does Process.Kill stop child processes started by the target process?

No. Kill and Signal apply only to the process you address; children are not killed automatically. Use a process group (SysProcAttr.Setpgid and a negative PID with syscall.Kill on Linux), track each child PID, or rely on a supervisor or container runtime when you need whole-tree shutdown.

4. How does exec.CommandContext kill a subprocess when the context ends?

When the context is canceled or its deadline passes, the os/exec package stops the child by interrupting it and then killing the process if needed; treat this as a timeout or cancellation hook and still read Wait errors so the child is reaped.

5. What mistakes cause silent failures or leaks when killing from Go?

Common issues are killing only the parent while grandchildren keep running, skipping Wait after Start, assuming Kill is graceful, signaling PIDs you do not own, using Unix-only signals without build constraints on Windows, and ignoring permission errors when signaling other users processes.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive …