ZetCode

Go exec command

last modified October 30, 2026

In this article we show how to execute external commands and programs in Go. The os/exec package starts a process, connects its standard input, output and error streams, and reports how the process ended.

Run starts a command and waits for it to finish. Start starts it and returns immediately; Wait then waits for the exit and releases the resources. Output and CombinedOutput are shortcuts that start the command, collect its output and wait.

The examples were compiled and run with Go 1.27.0 on Linux and use standard Unix programs such as echo, tr and sh. The examples that use files or a working directory run in /tmp/goexec.

Running a command

exec.Command takes the program name followed by its arguments. Output runs the command and returns its standard output.

main.go
package main

import (
    "fmt"
    "os/exec"
)

func main() {

    out, err := exec.Command("echo", "there", "are three", "falcons").Output()
    if err != nil {
        fmt.Println(err)
        return
    }

    fmt.Printf("%q\n", string(out))
}

The arguments are passed as separate strings, so the space inside "are three" does not split it into two arguments. The output includes the newline that echo writes.

$ go run main.go
"there are three falcons\n"

Standard error and exit codes

Output captures only standard output. When a command writes to standard error or exits with a non-zero status, the returned error is an *exec.ExitError, which carries the exit code.

main.go
package main

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

func main() {

    cmd := exec.Command("sh", "-c", "echo out; echo err >&2; exit 3")

    out, err := cmd.CombinedOutput()
    fmt.Printf("output: %q\n", string(out))
    fmt.Println("error: ", err)

    var exitErr *exec.ExitError
    if errors.As(err, &exitErr) {
        fmt.Println("code:  ", exitErr.ExitCode())
    }
}

CombinedOutput interleaves both streams into one slice. The error text is exit status 3, and errors.As exposes the numeric code for program logic.

$ go run main.go
output: "out\nerr\n"
error:  exit status 3
code:   3

Sending standard input

The Stdin field of a command accepts any io.Reader. A string reader is enough to feed a filter such as tr.

main.go
package main

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

func main() {

    cmd := exec.Command("tr", "a-z", "A-Z")
    cmd.Stdin = strings.NewReader("and old falcon\n")

    out, err := cmd.Output()
    if err != nil {
        fmt.Println(err)
        return
    }

    fmt.Printf("%q\n", string(out))
}

The command reads the string as if it came from a pipe or a terminal. The result is the upper-cased text followed by the original newline.

$ go run main.go
"AND OLD FALCON\n"

Connecting two commands

StdoutPipe returns a reader connected to the command's standard output. Assigning it to the next command's Stdin builds a pipeline in the same way as the shell's | operator.

main.go
package main

import (
    "fmt"
    "os/exec"
)

func main() {

    echo := exec.Command("echo", "one two three")
    upper := exec.Command("tr", "a-z", "A-Z")

    pipe, err := echo.StdoutPipe()
    if err != nil {
        fmt.Println(err)
        return
    }
    upper.Stdin = pipe

    if err := echo.Start(); err != nil {
        fmt.Println(err)
        return
    }

    out, err := upper.Output()
    if err != nil {
        fmt.Println(err)
        return
    }

    if err := echo.Wait(); err != nil {
        fmt.Println(err)
        return
    }

    fmt.Printf("%q\n", string(out))
}

The first command must be started before the second one can read from its pipe. Both processes run concurrently, and Wait collects the exit status of echo after the output has been consumed.

$ go run main.go
"ONE TWO THREE\n"

Streaming output line by line

For a long-running command, reading the whole output into memory is wasteful. StdoutPipe together with bufio.Scanner processes each line as soon as it is written.

main.go
package main

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

func main() {

    cmd := exec.Command("sh", "-c", "printf 'alpha\\nbeta\\ngamma\\n'")

    stdout, err := cmd.StdoutPipe()
    if err != nil {
        fmt.Println(err)
        return
    }

    if err := cmd.Start(); err != nil {
        fmt.Println(err)
        return
    }

    scanner := bufio.NewScanner(stdout)
    n := 0
    for scanner.Scan() {
        n++
        fmt.Printf("%d: %s\n", n, scanner.Text())
    }

    if err := scanner.Err(); err != nil {
        fmt.Println(err)
    }

    if err := cmd.Wait(); err != nil {
        fmt.Println(err)
    }
}

The loop runs while the pipe is open and the scanner has data. The scanner error is checked after the loop, and Wait reports a non-zero exit status of the child.

$ go run main.go
1: alpha
2: beta
3: gamma

Writing to a stdin pipe

StdinPipe returns a writer connected to the command's standard input. Writing must happen while the command runs, so it is usually done in a goroutine that closes the pipe when it is done.

main.go
package main

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

func main() {

    cmd := exec.Command("cat")
    stdin, err := cmd.StdinPipe()
    if err != nil {
        fmt.Println(err)
        return
    }

    go func() {
        defer stdin.Close()
        io.WriteString(stdin, "an old falcon\n")
    }()

    out, err := cmd.Output()
    if err != nil {
        fmt.Println(err)
        return
    }

    fmt.Printf("%q\n", string(out))
}

Closing the pipe is what signals end-of-file to cat. Without it, the command would wait for more input and Output would block.

$ go run main.go
"an old falcon\n"

Working directory and environment

Cmd.Dir sets the working directory of the child, and Cmd.Env replaces its environment. cmd.Environ returns a clean copy of the inherited environment that can be extended.

main.go
package main

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

func main() {

    cmd := exec.Command("sh", "-c", `pwd; echo "$APP_NAME"`)
    cmd.Dir = "work"
    cmd.Env = append(cmd.Environ(), "APP_NAME=falcon")

    out, err := cmd.Output()
    if err != nil {
        fmt.Println(err)
        return
    }

    fmt.Print(string(out))

    wd, _ := os.Getwd()
    fmt.Println("parent cwd:", wd)
}

The child runs in the work subdirectory while the parent keeps its own directory. The relative path in Dir is resolved against the parent's working directory.

$ go run main.go
/tmp/goexec/work
falcon
parent cwd: /tmp/goexec

Timeouts

exec.CommandContext ties the process to a context. When the context expires, the command is killed and the returned error reports the signal.

main.go
package main

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

func main() {

    ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
    defer cancel()

    cmd := exec.CommandContext(ctx, "sleep", "5")

    _, err := cmd.Output()
    fmt.Println("error:  ", err)
    fmt.Println("context:", ctx.Err())
    fmt.Println("deadline:", errors.Is(ctx.Err(), context.DeadlineExceeded))
}

The command would sleep for five seconds but is killed after one hundred milliseconds. The process error and the context error describe the same event from two sides.

$ go run main.go
error:   signal: killed
context: context deadline exceeded
deadline: true

Finding a program

exec.Command searches PATH when the program name contains no slash. exec.LookPath performs the same search without starting anything, which is useful for checking dependencies at startup.

main.go
package main

import (
    "fmt"
    "os/exec"
)

func main() {

    path, err := exec.LookPath("go")
    fmt.Println(path, err)

    _, err = exec.LookPath("no-such-program")
    fmt.Println(err)
}

The absolute path depends on the installation. A missing program produces an error that names the program and the searched variable.

$ go run main.go
/usr/local/go/bin/go <nil>
exec: "no-such-program": executable file not found in $PATH

Source

Go os/exec package - reference

In this article we have executed external commands in Go. We captured output and standard error, inspected exit codes, fed data through standard input, built pipelines, streamed output line by line, set the working directory and environment, applied a context timeout and looked up programs on the PATH.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Go tutorials.