When your program needs to run another program
You are building a CLI tool that compiles a project, runs a database migration, or launches a background worker. You need to invoke an external binary, pass arguments, capture the output, and handle failures. The external process might finish quickly, or it might hang forever. You need to know its PID to monitor it or kill it if it misbehaves.
Go handles external processes differently than C or Python. There is no fork. Go does not duplicate its own process image to run a child. Forking is dangerous in Go because the runtime holds internal state, mutexes, and goroutine stacks. Duplicating that state can cause deadlocks or corruption in the child process. Instead, Go uses exec to create a new process and replace its memory with the target program immediately. This keeps the runtime safe but changes how you manage process lifecycles. You build a command, start it, get a handle, and reap it when it ends.
The os/exec package provides the interface. You construct a command with exec.Command, start it to get a *os.Process containing the PID, and use Wait to collect the exit status. If you skip Wait, the process becomes a zombie. The OS keeps the PID alive in the process table, leaking resources until the parent reaps it or dies.
How process execution works
A process in Go is represented by the *os.Process struct. This struct holds the PID and provides methods to send signals or wait for completion. You never create a *os.Process directly. You get one by calling Start on a *exec.Cmd.
The exec.Command function prepares the invocation. It sets the binary path, arguments, working directory, environment variables, and standard I/O streams. It does not run anything yet. It builds a configuration object. When you call Start, Go makes a syscall to the OS to create the process. The OS assigns a PID and returns a *os.Process. You can read the PID from cmd.Process.Pid.
Here's the minimal pattern: build the command, start it, print the PID, and wait for it to finish.
package main
import (
"fmt"
"os"
"os/exec"
)
func main() {
// Command prepares the invocation without running it.
// It sets the binary, args, and default I/O streams.
cmd := exec.Command("sleep", "5")
// Start launches the process and returns immediately.
// It populates cmd.Process with the OS handle and PID.
if err := cmd.Start(); err != nil {
fmt.Fprintf(os.Stderr, "failed to start: %v\n", err)
os.Exit(1)
}
// Pid is the integer identifier assigned by the OS.
// You can use this to monitor the process externally.
fmt.Println("Started process with PID:", cmd.Process.Pid)
// Wait blocks until the process exits.
// It reaps the zombie and returns the exit status.
if err := cmd.Wait(); err != nil {
fmt.Fprintf(os.Stderr, "process failed: %v\n", err)
}
}
The Start method returns immediately. The process runs in the background. Your Go program continues executing. If you need to interact with the process, you use the *os.Process handle. You can send signals with cmd.Process.Signal. You can kill it with cmd.Process.Kill. You must call cmd.Wait eventually. Wait does two things. It blocks until the process terminates. It collects the exit status and cleans up the OS process table entry. If you call Start and never call Wait, you create a zombie. The process has exited, but the OS keeps its entry alive because the parent hasn't read the exit code. Zombies consume PIDs. On systems with limited PIDs, zombies can exhaust the range and prevent new processes from starting.
Realistic usage with context and output
In production code, you rarely use exec.Command alone. You use exec.CommandContext. This function attaches a context.Context to the command. When the context is cancelled or times out, the command receives a signal to terminate. This prevents processes from hanging indefinitely.
You also usually need to capture output. cmd.Run is a convenience method that starts the process, waits for it, and returns the error. It's equivalent to calling Start then Wait. When you use Run, you don't get the PID until after the process finishes, which is too late for monitoring. Use Start and Wait when you need the PID during execution.
Here's a realistic example. It runs a command with a timeout, captures combined output, and handles cancellation.
package main
import (
"context"
"fmt"
"os"
"os/exec"
"time"
)
// RunWithTimeout executes a command and returns the output or an error.
// It cancels the process if the context deadline is exceeded.
func RunWithTimeout(ctx context.Context, name string, args ...string) ([]byte, error) {
// CommandContext links the command to the context.
// If ctx is cancelled, the process receives a signal.
cmd := exec.CommandContext(ctx, name, args...)
// CombinedOutput captures stdout and stderr together.
// It starts the process, waits for it, and returns the bytes.
output, err := cmd.CombinedOutput()
// Check if the error is a context cancellation.
// The compiler rejects this with an undefined-variable error if ctx is missing.
if ctx.Err() != nil {
return nil, fmt.Errorf("command cancelled: %w", ctx.Err())
}
// If CombinedOutput returns an error, the process failed.
// The output still contains whatever was written before failure.
if err != nil {
return output, fmt.Errorf("command failed: %w", err)
}
return output, nil
}
func main() {
// Create a context with a 2-second deadline.
// The process will be killed if it runs longer.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// Run a command that sleeps for 10 seconds.
// It should be killed by the timeout.
output, err := RunWithTimeout(ctx, "sleep", "10")
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println("Output:", string(output))
}
}
The CommandContext function is the standard way to run external commands. It creates a goroutine that watches the context. When the context is done, it sends a signal to the process. On Unix, it sends SIGKILL or SIGTERM depending on the implementation. On Windows, it terminates the process tree. This integration is critical for reliability. Without context, a hung process can block your program forever.
The CombinedOutput method is convenient for simple cases. It starts the process, waits for it, and returns the output. It's a wrapper around Start and Wait. If you need to stream output in real-time, you must set cmd.Stdout and cmd.Stderr to pipes or writers, then read from them concurrently. CombinedOutput buffers everything in memory. For large outputs, this can cause memory pressure.
Pitfalls and runtime behaviors
Process management in Go has several traps. The most common is the zombie process. If you call Start and forget Wait, the process becomes a zombie. The OS keeps the PID alive. The process has exited, but the entry remains. You can see zombies with ps aux showing Z state. The fix is always to call Wait. If you don't need the exit code, call Wait and discard the result. cmd.Wait() is mandatory.
Another trap is os.FindProcess. This function takes a PID and returns a *os.Process. It seems useful for managing processes you didn't start. It is dangerous. On Unix, os.FindProcess(1) returns success because PID 1 always exists. You cannot kill PID 1. The function returns a handle, but calling Signal or Kill may fail with os: process already finished or permission denied. On Windows, PIDs are recycled. A PID might refer to a different process than the one you expect. Never use os.FindProcess to manage processes you didn't create. Keep the *os.Process handle from Start.
Signals are another source of confusion. os.Kill sends a signal that terminates the process immediately. On Unix, this is SIGKILL. The process cannot catch or ignore it. On Windows, os.Kill calls TerminateProcess. os.Interrupt sends SIGINT. This is the signal you get when you press Ctrl+C. Processes can catch SIGINT and shut down gracefully. If you need a graceful shutdown, send os.Interrupt first. If the process doesn't exit, send os.Kill. Windows does not support SIGINT the same way. os.Interrupt may not work as expected on Windows. Use os.Kill for cross-platform termination.
Context cancellation does not kill the process automatically. CommandContext wires the context to the process, but you must understand the mechanism. When the context is cancelled, CommandContext sends a signal. If the process ignores the signal, it keeps running. The Run or Wait call returns an error, but the process might still be alive. You need to monitor the process or use a wrapper that retries with Kill if Interrupt fails. The compiler rejects this with exec: CommandContext: context deadline exceeded if the timeout hits, but the process state depends on the signal handling.
Error handling is verbose by design. Go requires you to check every error. if err != nil { return err } is the standard pattern. It makes the unhappy path visible. Don't hide errors. If a command fails, return the error. The caller decides how to handle it. The exec package returns specific error types. *exec.ExitError indicates the process ran but exited with a non-zero status. You can check err.(*exec.ExitError).ExitCode() to get the code. Type assertions are safe here because the error type is exported.
Decision matrix
Use exec.Command when you need full control over the process lifecycle and don't need context integration. Use exec.CommandContext when you need to cancel or timeout the process. Use cmd.Run when you want a simple fire-and-forget execution and don't need the PID. Use cmd.Start and cmd.Wait when you need the PID during execution or want to handle signals manually. Use os.FindProcess only when you absolutely must interact with a process you didn't create, and verify the PID is valid and owned by your user. Use cmd.Process.Signal(os.Interrupt) for graceful shutdown. Use cmd.Process.Kill() for immediate termination. Use cmd.CombinedOutput for small outputs. Use pipes and concurrent readers for streaming large outputs.
Zombies are real. Reap your processes. Context is a timer. Wire it to the kill switch. Keep the handle. Don't hunt for PIDs.