This guide is for Go developers who need to run external programs from Go—whether that is a helper binary, git, or a short shell pipeline—and want to capture output, pass arguments safely, and avoid hanging or insecure patterns. It centers on os/exec: direct execution with an argument list, optional shell use for pipes and globs, timeouts with CommandContext, and stdin, environment, and working-directory setup. The examples use os/exec and are meant to be run on your machine with go run (Unix-style paths); a short note on Windows appears where behavior differs. For how argv is split—both for subprocesses you start and for your own program—see command-line arguments in Go. Further reading on context, os, and background processes is listed under References.
Go 1.24 on Linux.
Quick answer: run external commands from Go
Use the os/exec package: build a command with exec.Command, pass the program path or name plus each argument separately, then call Run (wait, no captured stdout), Output (stdout bytes), CombinedOutput (stdout and stderr merged), or Start followed by Wait. For deadlines and cancellation, use exec.CommandContext.
Run commands in Go with os/exec
exec.Command basics
exec.Command(name, arg...) returns an *exec.Cmd. The first parameter is the program to run; remaining parameters are argv entries (not a single shell line). Go sets Cmd.Args to []string{name, arg...} and resolves name through LookPath when it contains no slash. That mirrors argv for your own program: one string per slot, without a shell parsing the line.
Command path and arguments
Prefer exec.Command("git", "status", "-sb") over stuffing "git status -sb" into one string. Quoting rules you use in an interactive terminal are handled by the shell; os/exec passes argv directly to the program without a shell unless you invoke one.
package main
import (
"fmt"
"os/exec"
)
func main() {
cmd := exec.Command("ls", "-l")
out, err := cmd.Output()
if err != nil {
panic(err)
}
fmt.Print(string(out))
}When you run this locally, you should see a long-form directory listing for the current folder (exact lines depend on your filesystem).
Run a command and wait
Run
Run() starts the process, waits until it exits, and returns an error if start-up fails or the exit code is non-zero. Unlike Output and CombinedOutput, it does not return captured stdout; assign cmd.Stdout / cmd.Stderr (or use Output) when you need bytes.
package main
import "os/exec"
func main() {
if err := exec.Command("true").Run(); err != nil {
panic(err)
}
}A successful run returns a nil error.
Output
Output() runs the command and returns stdout only as a []byte. Stderr is not part of that slice, so failure messages on stderr are easy to miss unless you wire cmd.Stderr, use CombinedOutput(), or use the split-buffer pattern in Capture stdout and stderr below. The ls example under Command path and arguments already demonstrates Output() on success.
CombinedOutput
CombinedOutput() is like Output, but merges stderr into the returned slice (interleaving order can vary). Handy for quick scripts; less ideal when you must treat stdout and stderr separately.
package main
import (
"fmt"
"os/exec"
)
func main() {
out, err := exec.Command("sh", "-c", "echo hi; echo err >&2").CombinedOutput()
if err != nil {
panic(err)
}
fmt.Print(string(out))
}Locally you should see hi, a newline, then err (both streams end up in the printed blob).
Start and Wait
Start() launches the child without waiting; Wait() blocks until the process exits and I/O copying finishes. Use this when you need to stream I/O, overlap work, or integrate with the background-process patterns in monitor a background process in Go.
package main
import (
"fmt"
"os/exec"
)
func main() {
cmd := exec.Command("sleep", "1")
if err := cmd.Start(); err != nil {
panic(err)
}
fmt.Println("started")
if err := cmd.Wait(); err != nil {
panic(err)
}
fmt.Println("done")
}You should see started, then about one second later done.
Capture stdout and stderr
External commands can write to two different streams:
| Stream | Meaning |
|---|---|
| stdout | normal command output |
| stderr | error messages, warnings, diagnostics |
Capture only stdout
package main
import (
"fmt"
"log"
"os/exec"
)
func main() {
cmd := exec.Command("sh", "-c", "echo stdout; echo stderr >&2")
out, err := cmd.Output()
if err != nil {
log.Fatal(err)
}
fmt.Print(string(out))
}The stderr line is not included in out. Use this when success-path stdout is all you need.
Capture stdout and stderr together
Use CombinedOutput() when you want stdout and stderr in one combined byte slice.
package main
import (
"fmt"
"log"
"os/exec"
)
func main() {
cmd := exec.Command("sh", "-c", "echo stdout; echo stderr >&2")
out, err := cmd.CombinedOutput()
if err != nil {
log.Fatal(err)
}
fmt.Print(string(out))
}Both lines appear in the printed blob (order of interleaving can vary slightly). Use this when you want one combined log for troubleshooting.
Capture stderr separately
Wire Stdout and Stderr to your own buffers and call Run().
package main
import (
"bytes"
"fmt"
"log"
"os/exec"
)
func main() {
cmd := exec.Command("sh", "-c", "echo stdout; echo stderr >&2")
var outb, errb bytes.Buffer
cmd.Stdout = &outb
cmd.Stderr = &errb
if err := cmd.Run(); err != nil {
log.Fatal(err)
}
fmt.Print("OUT:", outb.String())
fmt.Print("ERR:", errb.String())
}You should see OUT:stdout and ERR:stderr (each followed by a newline from echo). Use this when your program must treat normal output and diagnostics differently.
Pass arguments safely
Pass each argument separately
Build argv explicitly. Each argument should be passed as a separate string.
package main
import (
"fmt"
"os/exec"
)
func main() {
userMessage := "Hello; rm -rf /"
out, err := exec.Command("echo", userMessage).Output()
if err != nil {
panic(err)
}
fmt.Print(string(out))
}When you run this locally, the line printed is literally Hello; rm -rf / (including the semicolon and spaces). The shell does not parse ; here because no shell is involved.
This is the safer pattern when passing user input: exec.Command("grep", pattern, path) with each value as its own argument, instead of interpolating them into one shell string.
Avoid building one command string for os/exec
exec.Command does not run a shell. If you write exec.Command("ls -la /tmp"), Go looks for a single executable whose name contains spaces—almost certainly not what you want.
Instead, split the program and arguments: exec.Command("ls", "-la", "/tmp").
Pipes, redirects, globs, variables, and && are not handled by argv-style exec.Command; use a shell only when you need that behavior (see Run shell commands below).
Why quoting works differently than a terminal
In a terminal, the shell parses quotes and backslashes before the program runs.
With os/exec, each Go string is already one argument. You usually do not need shell-style quoting, even when the value contains spaces.
package main
import (
"fmt"
"os/exec"
)
func main() {
msg := "Hello, World!"
out, err := exec.Command("echo", msg).Output()
if err != nil {
panic(err)
}
fmt.Print(string(out))
n := 42
out2, err := exec.Command("echo", fmt.Sprintf("%d", n)).Output()
if err != nil {
panic(err)
}
fmt.Print(string(out2))
}You should see the greeting and 42, each on its own line. Format numbers for argv with fmt.Sprintf or strconv; see strconv in Go for parsing and formatting patterns.
Run shell commands
When exec.Command is enough
If the program is a normal binary on PATH, exec.Command("tool", "arg1", "arg2") is enough.
No shell is involved, which reduces surprises and avoids shell injection risks.
Example:
package main
import (
"fmt"
"os/exec"
)
func main() {
out, err := exec.Command("date", "+%Y-%m-%d").Output()
if err != nil {
panic(err)
}
fmt.Print(string(out))
}Here, Go runs the date binary directly and passes +%Y-%m-%d as one argument. Use this style whenever you do not need shell-only features (pipes, redirects, globs, &&, variable expansion in the shell).
When you need sh -c or bash -c
Use a shell when you need pipes, && / ||, redirects, globs expanded by the shell, variables, or builtins such as cd in the same string.
package main
import (
"fmt"
"os/exec"
)
func main() {
cmd := exec.Command("bash", "-c", "printf 'a\nb\n' | wc -l")
out, err := cmd.Output()
if err != nil {
panic(err)
}
fmt.Print(string(out))
}Locally you should see a line count, such as 2.
Pipes, redirects, wildcards, and environment expansion
Pipes, redirects, wildcards, variables, and command chaining belong to shell languages, not to os/exec itself.
For example, this needs a shell because | is shell syntax:
package main
import (
"fmt"
"os/exec"
)
func main() {
cmd := exec.Command("sh", "-c", `printf "one\ntwo\n" | wc -l`)
out, err := cmd.Output()
if err != nil {
panic(err)
}
fmt.Print(string(out))
}When you run this locally, you should see a line containing the digit 2.
Without a shell, Go treats each argument literally. exec.Command("printf", "one\ntwo\n", "|", "wc", "-l") passes five argv strings (printf, the two-line text, |, wc, -l); it does not create a pipe between processes.
If you want finer control and no shell parser in the middle, connect commands in Go:
package main
import (
"fmt"
"os/exec"
)
func main() {
printf := exec.Command("printf", "one\ntwo\n")
wc := exec.Command("wc", "-l")
r, err := printf.StdoutPipe()
if err != nil {
panic(err)
}
wc.Stdin = r
if err := printf.Start(); err != nil {
panic(err)
}
out, err := wc.Output()
if err != nil {
panic(err)
}
if err := printf.Wait(); err != nil {
panic(err)
}
fmt.Print(string(out))
}This is more code, but it avoids shell parsing and gives your program control over each process.
Bash on Linux and macOS
On many Unix systems, /bin/sh is POSIX sh; on Debian/Ubuntu it is often dash, not Bash.
Use bash -c only when you need Bash-specific syntax.
For example, brace expansion is a Bash feature:
package main
import (
"fmt"
"os/exec"
)
func main() {
cmd := exec.Command("bash", "-c", "echo file-{1..3}.txt")
out, err := cmd.Output()
if err != nil {
panic(err)
}
fmt.Print(string(out))
}When you run this locally with Bash available, you should see three filenames separated by spaces (file-1.txt, file-2.txt, file-3.txt). The same echo file-{1..3}.txt under sh -c can fail or behave differently on systems where /bin/sh is not Bash.
Keep the script string trusted or build it only from strictly validated pieces. Do not place raw user input directly inside a shell command string.
Windows shell difference
On Windows, exec.Command still takes a program name plus argv elements, but shell-like behavior usually goes through cmd.exe /c or PowerShell.
Example with cmd.exe (run on Windows; on Linux this typically fails because cmd.exe is not on PATH):
package main
import (
"fmt"
"os/exec"
)
func main() {
cmd := exec.Command("cmd.exe", "/c", "echo hello && echo world")
out, err := cmd.Output()
if err != nil {
panic(err)
}
fmt.Print(string(out))
}Quoting rules differ between POSIX shells, cmd.exe, and PowerShell. If you ship cross-platform CLIs, test command execution on each target OS instead of assuming Linux shell behavior will match Windows.
Add timeout or cancellation
CommandContext
exec.CommandContext ties the child’s lifetime to a context.Context. When the context is canceled or its deadline passes, the process is stopped. Pairing defer cancel() with context.WithTimeout matches the pattern described in defer keyword in Go.
package main
import (
"context"
"fmt"
"os/exec"
"time"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
cmd := exec.CommandContext(ctx, "sleep", "10")
if err := cmd.Run(); err != nil {
fmt.Println("expected:", err)
}
}On Linux you will typically see an error such as signal: killed because the timeout stops the long sleep early. Combine with Go context for app-wide cancellation.
Kill long-running commands and avoid hanging forever
Without a context deadline, a blocked child can block your goroutine indefinitely. Prefer CommandContext for network-facing or user-triggered commands, and always drain or close pipes when you use streaming I/O to avoid deadlocks (see the Common mistakes section below).
Provide stdin to a command
Set Cmd.Stdin to an io.Reader—for example strings.NewReader for small input or an *os.File for larger streams. File-backed stdin uses the same os APIs summarized in the Set environment and working directory section below.
package main
import (
"bytes"
"fmt"
"log"
"os/exec"
"strings"
)
func main() {
cmd := exec.Command("cat")
cmd.Stdin = strings.NewReader("hello\n")
var buf bytes.Buffer
cmd.Stdout = &buf
if err := cmd.Run(); err != nil {
log.Fatal(err)
}
fmt.Print(buf.String())
}You should see hello followed by a newline. Interactive TTY programs usually need more than stdin bytes (terminal emulation); for those cases run locally and consider a PTY library outside this scope.
Set environment and working directory
Cmd.Dir sets the child’s working directory. Cmd.Env replaces the entire environment if you assign a non-nil slice—usually you append(os.Environ(), "KEY=value") to inherit the parent environment. os.Environ, opening files (for example stdin), and other process helpers are covered in the os package in Go.
package main
import (
"fmt"
"log"
"os"
"os/exec"
)
func main() {
cmd := exec.Command("bash", "-c", "pwd; echo demo=$DEMO")
cmd.Dir = "/tmp"
cmd.Env = append(os.Environ(), "DEMO=from_go")
out, err := cmd.Output()
if err != nil {
log.Fatal(err)
}
fmt.Print(string(out))
}Locally you should see /tmp on the pwd line and demo=from_go from the expanded variable.
LookPath resolves program names using the inherited PATH unless you override Env; if you replace Env entirely, include PATH yourself when you still need lookup by bare name.
Security: avoid command injection
Do not pass user input into shell strings
Avoid building shell commands from untrusted input. If you interpolate into bash -c, sh -c, cmd.exe /c, or PowerShell strings, metacharacters such as ;, |, `, $(...), and redirects can be interpreted as extra commands.
package main
import (
"fmt"
"os/exec"
)
func main() {
user := "test.txt; rm -rf /tmp/demo"
cmd := exec.Command("bash", "-c", fmt.Sprintf("cat %s", user))
// Illustration only — do not Run() untrusted -c scripts in real code.
fmt.Println(cmd.Args)
}Locally you will see the -c argument is a single string that still contains the semicolon; when you did run this with Run(), Bash would treat ; as a command separator (so the risk is real). Treat any user-derived text inside those -c strings as hostile.
Prefer direct command arguments
When possible, run the program directly and pass user-controlled values as separate argv entries so the shell never parses them.
package main
import (
"fmt"
"os/exec"
)
func main() {
userFile := "test.txt; rm -rf /tmp/demo"
cmd := exec.Command("cat", userFile)
fmt.Println(cmd.Args)
}Here the entire payload is one argv element after cat. The shell is not in the path, so ;, |, `, $(...), redirects, and globs are not interpreted as shell syntax (only cat’s own rules apply—usually “open one path”).
Compare argv-style grep with a shell string you must not build from raw users:
package main
import (
"fmt"
"os/exec"
)
func main() {
searchText := "TODO"
fileName := "./README.md"
safe := exec.Command("grep", searchText, fileName)
unsafe := fmt.Sprintf("grep %s %s", searchText, fileName)
bad := exec.Command("bash", "-c", unsafe)
fmt.Println("safe argv:", safe.Args)
fmt.Println("bad -c:", bad.Args)
}Validate or whitelist allowed values
If you must go through a shell, do not “sanitize” with ad‑hoc string removal; allowlists and strict validation are easier to reason about than quoting rules across Linux, macOS, and Windows.
Map high-level user choices to fixed scripts you control:
package main
import (
"fmt"
"os/exec"
)
func main() {
allowed := map[string]string{
"list": "ls -la",
"whoami": "whoami",
}
userChoice := "list"
script, ok := allowed[userChoice]
if !ok {
panic("invalid command")
}
cmd := exec.Command("bash", "-c", script)
fmt.Println(cmd.Args)
}For numeric input, parse and validate before use (strconv.Atoi, bounds checks, and related helpers—patterns are in the strconv article linked under References):
package main
import (
"fmt"
"strconv"
)
func main() {
userID := "42"
id, err := strconv.Atoi(userID)
if err != nil || id <= 0 {
panic("invalid id")
}
fmt.Println("ok:", id)
}Common mistakes
-
Expecting shell syntax without a shell:
exec.Command("echo", "foo > file")passes a literal argument containing>; it does not redirect to a file. -
Passing the whole command as one string to
exec.Command: use multiple arguments or an explicitsh -c/bash -cstring you control. -
Ignoring stderr:
Output()drops stderr on the floor unless you wireStderr; checkCombinedOutputor separate buffers when failures are diagnosed on stderr. -
Forgetting timeouts: wrap long or untrusted work in
CommandContextwith a deadline. -
Deadlock with pipes: if you attach
StdoutPipe/StderrPipe, read from the pipes concurrently or beforeWaitcompletes; full pipes block the child. -
Unsafe user input in
sh -c: classic command injection; use argv-styleexec.Commandwhenever possible.
Go exec cheat sheet
| Goal | Recommended approach |
|---|---|
| Run external program | exec.Command |
| Wait for completion | Run |
| Capture stdout | Output |
| Capture stdout + stderr | CombinedOutput |
| Start process manually | Start + Wait |
| Add timeout/cancel | CommandContext |
| Pass arguments safely | separate Command arguments |
| Use pipe/redirection/glob | run through sh -c / bash -c or wire pipes in Go |
| Set working directory | Cmd.Dir |
| Set environment | Cmd.Env (often append(os.Environ(), "K=v")) |
| Send stdin | Cmd.Stdin as io.Reader |
| Avoid injection | never splice raw user text into shell -c strings |
Summary
Golang exec flows use os/exec to run external commands: exec.Command with argv-style arguments for direct binaries, then Run, Output, CombinedOutput, or Start/Wait depending on whether you need captured output or streaming. exec.CommandContext adds timeouts and cancellation. Stdout and stderr can be merged or captured separately by wiring Stdout and Stderr. Shell features such as pipes, globs, and && require an actual shell such as bash -c, which increases risk—avoid interpolating user input into those strings and prefer validated argv calls. Windows callers may use cmd.exe /c with different quoting rules. Related on-site tutorials are linked once in the sections above and collected under References.
References
- Package os/exec (Windows and subprocess notes)
- Package context
- Go context in applications
- Monitor background process
- Command-line arguments in Go
- The os package in Go
- strconv in Go
- Defer keyword in Go
- Convert int to string

