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:
- How to install Go in Fedora and Rocky Linux/CentOS/RHEL
- Script to upload files to AWS S3 using Golang
- List, upload and download files from an SFTP server using Golang
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.Commandwith 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 usedOutput(),Stderrholds 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 to | Use |
|---|---|
| Show output live on the terminal | cmd.Stdout = os.Stdout, then cmd.Run() |
| Get stdout as a value | cmd.Output() |
| Get stdout and stderr together | cmd.CombinedOutput() |
| Handle stdout and stderr separately | two bytes.Buffers, then cmd.Run() |
| Stop a command that takes too long | exec.CommandContext with a timeout |
| Use pipes, redirects or globs | exec.Command("sh", "-c", ...) with trusted input only |