Golang exec Command: Run Shell Commands, Capture Output, and Handle Errors

Tech reviewed: Deepak Prasad
Golang exec Command: Run Shell Commands, Capture Output, and Handle Errors

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.

go
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.

go
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.

go
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.

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

go
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.

go
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().

go
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.

go
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.

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

go
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.

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

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

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:

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

go
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.

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.

go
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.

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.

go
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.

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

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

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

go
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 explicit sh -c / bash -c string you control.

  • Ignoring stderr: Output() drops stderr on the floor unless you wire Stderr; check CombinedOutput or separate buffers when failures are diagnosed on stderr.

  • Forgetting timeouts: wrap long or untrusted work in CommandContext with a deadline.

  • Deadlock with pipes: if you attach StdoutPipe / StderrPipe, read from the pipes concurrently or before Wait completes; full pipes block the child.

  • Unsafe user input in sh -c: classic command injection; use argv-style exec.Command whenever 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


Frequently Asked Questions

1. How do I run an external command from Go?

Use os/exec: exec.Command with the program name and separate arguments, then call Run, Output, CombinedOutput, or Start followed by Wait depending on whether you need captured output.

2. What is the difference between exec.Command("ls", "-l") and one string with the whole command?

The first runs the ls binary with argv ["ls","-l"]; the second is not supported as a single string—split into program plus args, or invoke a shell with sh -c only when you truly need shell syntax.

3. When should I use Output versus CombinedOutput?

Output returns stdout only; CombinedOutput merges stderr into the same byte slice, which is convenient for quick diagnostics but loses the ability to treat streams independently unless you wire Stdout and Stderr yourself.

4. How do I time out or cancel a child process?

Use exec.CommandContext with a context that has a deadline or cancel function; when the context ends, the runtime stops the child process.

5. Why is fmt.Sprintf into bash -c dangerous?

Interpolating user-controlled text into a shell script allows command injection; pass user values as separate exec arguments or validate against a strict allowlist before building any shell string.

6. Can I run these snippets in the browser Run button?

Treat subprocess examples as local-only: save the file and use go run on your machine so os/exec can start real programs and use your PATH.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with over a decade of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, …

  • Red Hat Certified System Administrator in Red Hat OpenStack
  • Certified Kubernetes Application Developer (CKAD)
  • Red Hat Certified Specialist in Ansible Automation
  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security