How to Execute Shell Commands in Go Using os/exec

Run shell commands in Go with os/exec: capture output, pass stdin, split stdout and stderr, read exit codes, add timeouts, set env and dir, and avoid injection.

You can execute shell commands using the os/exec standard package. It needs no third-party dependency, and it covers everything from a one-off ls to long-running commands with timeouts. Every example below is a complete main.go that was run with Go 1.27 on Linux, and the output shown is what it printed.

Related content:

Run a command and stream its output

package main

import (
	"log"
	"os"
	"os/exec"
)

func main() {
	cmd := exec.Command("ls", "-la")
	cmd.Dir = "/"

	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	if err := cmd.Run(); err != nil {
		log.Fatalf("could not run the command: %v", err)
	}
}

From the above:

  • we define our command with exec.Command with the first argument being the command we want to run (ls).
  • Then put the rest of the arguments needed by your program (you can have as many as you want). Each argument is passed to the program as-is: there is no shell, so no globbing, pipes or $VARIABLES.
  • we define where we want our program to run with cmd.Dir, in my case the /
  • we define how we want the output to be redirected. In my case I send stdout and stderr straight to the terminal.
  • finally run your command with cmd.Run, which starts the command and waits for it to finish.

Output (trimmed):

total 12
drwxr-xr-x   1 root root  36 Nov 10 08:19 .
drwxr-xr-x   1 root root  36 Nov 10 08:19 ..
-rwxr-xr-x   1 root root   0 Nov 10 08:19 .dockerenv
lrwxrwxrwx   1 root root   7 Jul 17 17:05 bin -> usr/bin
drwxr-xr-x   1 root root   0 Jul 17 17:05 boot

A *exec.Cmd can only be run once. Build a new one with exec.Command for every run.

Capture output with Output

When you want the output in a variable rather than on the terminal, use Output(). It runs the command and returns its stdout as a []byte:

package main

import (
	"fmt"
	"log"
	"os/exec"
	"strings"
)

func main() {
	out, err := exec.Command("uname", "-sm").Output()
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("kernel: %s\n", strings.TrimSpace(string(out)))
}
kernel: Linux aarch64

Command output almost always ends with a newline, so trim it before you compare or print it.

Capture stdout and stderr together with CombinedOutput

CombinedOutput() returns stdout and stderr interleaved in one slice, which is what you want for logging a failed command:

package main

import (
	"fmt"
	"os/exec"
)

func main() {
	out, err := exec.Command("ls", "/etc/hostname", "/does-not-exist").CombinedOutput()
	fmt.Printf("%s", out)
	if err != nil {
		fmt.Println("error:", err)
	}
}
ls: cannot access '/does-not-exist': No such file or directory
/etc/hostname
error: exit status 2

Note that the output is still returned when the command fails, so print it before you bail out.

Keep stdout and stderr separate

To handle the two streams differently, point cmd.Stdout and cmd.Stderr at two buffers:

package main

import (
	"bytes"
	"fmt"
	"os/exec"
)

func main() {
	var stdout, stderr bytes.Buffer

	cmd := exec.Command("ls", "/etc/hostname", "/does-not-exist")
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr

	err := cmd.Run()
	fmt.Printf("stdout: %q\n", stdout.String())
	fmt.Printf("stderr: %q\n", stderr.String())
	fmt.Printf("err:    %v\n", err)
}
stdout: "/etc/hostname\n"
stderr: "ls: cannot access '/does-not-exist': No such file or directory\n"
err:    exit status 2

Pass input on stdin

cmd.Stdin takes any io.Reader: a string, a file, or another command’s output.

package main

import (
	"fmt"
	"log"
	"os/exec"
	"strings"
)

func main() {
	cmd := exec.Command("sort")
	cmd.Stdin = strings.NewReader("banana\ncherry\napple\n")

	out, err := cmd.Output()
	if err != nil {
		log.Fatal(err)
	}
	fmt.Print(string(out))
}
apple
banana
cherry

Read the exit code with exec.ExitError

Run, Output and CombinedOutput return two kinds of error, and you usually want to treat them differently:

  • *exec.ExitError: the command ran and exited with a non-zero status. ExitCode() gives you the status, and when you used Output(), Stderr holds what the command wrote to stderr.
  • any other error: the command never started, for example the binary is not on PATH.
package main

import (
	"errors"
	"fmt"
	"os/exec"
)

func run(name string, args ...string) {
	_, err := exec.Command(name, args...).Output()

	var exitErr *exec.ExitError
	switch {
	case err == nil:
		fmt.Printf("%s: succeeded\n", name)
	case errors.As(err, &exitErr):
		// The command ran and exited non-zero.
		fmt.Printf("%s: exit code %d, stderr: %s", name, exitErr.ExitCode(), exitErr.Stderr)
	default:
		// The command never started: not found, not executable, ...
		fmt.Printf("%s: could not start: %v\n", name, err)
	}
}

func main() {
	run("true")
	run("ls", "/does-not-exist")
	run("no-such-binary")
}
true: succeeded
ls: exit code 2, stderr: ls: cannot access '/does-not-exist': No such file or directory
no-such-binary: could not start: exec: "no-such-binary": executable file not found in $PATH

Use errors.As rather than a type assertion: it still works when the error has been wrapped with fmt.Errorf("...: %w", err) further up the call stack.

Add a timeout with CommandContext

A command that hangs will hang your program with it. exec.CommandContext kills the process when the context is cancelled or its deadline passes:

package main

import (
	"context"
	"errors"
	"fmt"
	"os/exec"
	"time"
)

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	start := time.Now()
	err := exec.CommandContext(ctx, "sleep", "10").Run()
	fmt.Printf("returned after %s\n", time.Since(start).Round(100*time.Millisecond))
	fmt.Println("err:", err)

	if errors.Is(ctx.Err(), context.DeadlineExceeded) {
		fmt.Println("the command timed out and was killed")
	}
}
returned after 2s
err: signal: killed
the command timed out and was killed

The error itself only says signal: killed. Check ctx.Err() to tell a timeout apart from a process that was killed by something else.

Set environment variables and the working directory

cmd.Dir sets the working directory. cmd.Env replaces the whole environment, so start from os.Environ() and append to it unless you really want an empty environment (which also drops PATH):

package main

import (
	"fmt"
	"log"
	"os"
	"os/exec"
)

func main() {
	cmd := exec.Command("sh", "-c", `echo "dir=$(pwd) app_env=$APP_ENV"`)
	cmd.Dir = "/tmp"
	// Start from the parent's environment and add to it.
	cmd.Env = append(os.Environ(), "APP_ENV=staging")

	out, err := cmd.Output()
	if err != nil {
		log.Fatal(err)
	}
	fmt.Print(string(out))
}
dir=/tmp app_env=staging

Use pipes and globs with sh -c

exec.Command does not start a shell, so |, >, * and && are passed to the program as literal arguments. When you need them, run the command through sh -c:

package main

import (
	"fmt"
	"log"
	"os/exec"
)

func main() {
	out, err := exec.Command("sh", "-c", "ls /etc | grep -c conf").Output()
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("files in /etc matching conf: %s", out)

	// Never do this with untrusted input:
	userInput := "nothing; echo INJECTED"
	out, _ = exec.Command("sh", "-c", "echo "+userInput).Output()
	fmt.Printf("unsafe:\n%s", out)

	// Pass it as an argument instead; no shell parses it.
	out, _ = exec.Command("echo", userInput).Output()
	fmt.Printf("safe:\n%s", out)
}
files in /etc matching conf: 14
unsafe:
nothing
INJECTED
safe:
nothing; echo INJECTED

That second block is a command injection: the shell treated the ; in the input as a command separator and ran echo INJECTED. With a value from an HTTP request or a file, that could just as easily be rm -rf. Never build a sh -c string from user input. Pass untrusted values as separate arguments to exec.Command, where they reach the program unparsed. If you must use a shell, pass the value as a positional parameter instead of concatenating it: exec.Command("sh", "-c", `echo "$1"`, "sh", userInput).

Check a binary exists with exec.LookPath

exec.LookPath searches PATH the same way exec.Command does, which lets you fail early with a clear message:

package main

import (
	"fmt"
	"os/exec"
)

func main() {
	for _, name := range []string{"git", "kubectl"} {
		path, err := exec.LookPath(name)
		if err != nil {
			fmt.Printf("%s: not installed (%v)\n", name, err)
			continue
		}
		fmt.Printf("%s: found at %s\n", name, path)
	}
}
git: found at /usr/bin/git
kubectl: not installed (exec: "kubectl": executable file not found in $PATH)

Since Go 1.19, exec.Command and LookPath refuse to run a binary found through a relative PATH entry such as ., and return an exec.ErrDot error instead. If you really mean a binary in the current directory, call it as ./tool.

Which method should I use?

You want toUse
Show output live on the terminalcmd.Stdout = os.Stdout, then cmd.Run()
Get stdout as a valuecmd.Output()
Get stdout and stderr togethercmd.CombinedOutput()
Handle stdout and stderr separatelytwo bytes.Buffers, then cmd.Run()
Stop a command that takes too longexec.CommandContext with a timeout
Use pipes, redirects or globsexec.Command("sh", "-c", ...) with trusted input only
Last updated on Nov 10, 2026 12:49 +0300
comments powered by Disqus
Citizix Ltd
Built with Hugo
Theme Stack designed by Jimmy