Skip to content
shellhacks.dev
Go

Go vs Bash for Automation and DevOps Tasks With Examples

Go vs Bash for automation and DevOps tasks, with side by side examples of API calls, JSON handling, retries and concurrency, and a choice rule.

6 min read
Summarize in AI
Illustration for "Go vs Bash for Automation and DevOps Tasks With Examples": a shell script in an editor, a request travelling to a server, a process monitor.

The earlier discussion in tradeoffs between Go and Bash was general. This one is task by task, comparing the same automation jobs in each language so the difference shows in code. The assumptions are Bash 5.2 with curl and jq, and Go 1.23 with only the standard library.

Task 1: call an API and read JSON

Shell
status=$(curl -fsS --max-time 10 https://api.example.test/v1/jobs/42 | jq -r '.status')
echo "job 42 is $status"
Go
type job struct {
	Status string `json:"status"`
}

func jobStatus(id int) (string, error) {
	client := &http.Client{Timeout: 10 * time.Second}
	resp, err := client.Get(fmt.Sprintf("https://api.example.test/v1/jobs/%d", id))
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("status %d", resp.StatusCode)
	}
	var j job
	if err := json.NewDecoder(resp.Body).Decode(&j); err != nil {
		return "", err
	}
	return j.Status, nil
}

For one call, Bash wins on length by a wide margin. The Go version is more code, but each failure has a place: network error, bad status, bad JSON. In Bash, curl -f and pipefail catch the first two if you remember them. The gap closes as the number of calls and the amount of data handling grow.

Task 2: retry with backoff

Shell
for i in 1 2 3 4 5; do
  curl -fsS --max-time 10 https://api.example.test/ping && break
  sleep $((2 ** i))
done
Go
func withRetry(attempts int, fn func() error) error {
	var err error
	for i := 0; i < attempts; i++ {
		if err = fn(); err == nil {
			return nil
		}
		time.Sleep(time.Duration(1<<i) * time.Second)
	}
	return err
}

Both are short. The Go function is reusable for any operation and returns the last error, while the Bash loop needs a copy for each command and loses the exit status unless you capture it. For rate limits, see retries and rate limits.

Task 3: run things in parallel

Shell
for host in "${hosts[@]}"; do
  ssh -o BatchMode=yes "$host" 'systemctl restart app' &
done
wait

Bash gets parallel jobs with & and wait, but it loses individual exit statuses unless you track PIDs, and limiting to ten at a time needs extra code. Go's goroutines with a worker pool and errgroup give bounded concurrency and collected errors in a dozen lines. Ten servers, Bash is fine. Five hundred with reporting, Go is calmer. Compare with running a command on many servers.

Task 4: files and processes

Shell
find /var/log -name '*.log' -mtime +30 -delete
tar -czf "backup-$(date +%F).tgz" /srv/data

This is Bash territory: two lines that call existing tools. Reproducing find -mtime and tar in Go means walking directories and writing archive code, or shelling out to the same commands with os/exec, as in running commands from Go, which gives up Go's advantages. Keep file system glue in shell.

Where Go wins and where Bash does

The comparison is clearest when each language does what it is built for. Bash is glue: it starts programs, connects their input and output, and expresses a sequence of steps in few lines. Go is a programming language with types, concurrency primitives and a standard library that includes HTTP, JSON, cryptography and testing. A task that is mostly "run these commands in order" belongs in Bash. A task that parses structured data, handles errors on many paths, or runs for a long time belongs in Go. The grey area in between is large, and the decision usually turns on how long the program will live and how many people will maintain it.

The cost of the wrong choice shows up late. A Bash script grows conditionals until it cannot be tested or reasoned about. A Go program built for a five-line problem takes an hour to compile, package and distribute when a script would have been done in a minute.

Distribution and dependencies

A Go program compiles to a single static binary for each platform, with no interpreter and no runtime to install. Copying one file to a server is a deployment. Cross-compiling for Linux, macOS and Windows is a matter of setting two environment variables. A Bash script depends on the shell version, on the exact flavours of sed, date and grep on the target, and on every external tool being present, which turns portability into a test matrix. The posts on macOS versus Linux differences show how large that matrix is.

The reverse holds for small utilities on a machine you control. If the host already has Bash, curl and jq, a script has nothing to install and can be edited in place during an incident, which a compiled binary cannot.

Error handling and testing

Go makes errors explicit values that every call returns, so a missing check is visible in review and linters flag ignored errors. Bash relies on exit statuses that are easy to ignore, on set -e behaviour with surprising exceptions, and on pipelines whose failures disappear without pipefail. The difference is largest in code that must handle partial failure, such as retries, cleanup and rollbacks.

Testing follows the same line. Go has go test built in, with table-driven tests, fakes through interfaces and a race detector for concurrent code. Bash testing is possible with tools like Bats, but isolating functions from the commands they call takes discipline. If a script is important enough that a bug costs real money, the ability to test it thoroughly is a strong argument for moving it.

A practical rule for migrating

Do not rewrite a working script because a language is nicer. Rewrite when the script has become a program: it has its own configuration file, it parses more than trivial text, it needs concurrency, several people change it, or it must run on systems you do not control. Move in stages, keeping the shell interface identical so callers do not change, and let the Go version call the old script for steps not yet ported. Compare the output of old and new on real inputs before switching. Keep Bash for the wrapper that sets up the environment and starts the binary, which is the thing it does best.

Whatever the choice, keep the interface stable. A command line with clear flags, predictable exit codes and output that scripts can parse lets either implementation sit behind it. Users and cron jobs then care about the contract, not the language, and the team can change the engine later without a coordinated migration.

Also weigh the people involved. A language the team does not know well is a maintenance risk however good its technical fit. A tool written in Go by someone who left, with nobody who reads Go, becomes as opaque as an old shell script, so match the choice to the skills on hand or plan the training that makes the choice sustainable.

Where this does not apply

A common pragmatic design is a Go binary that does the logic, with a short Bash wrapper that sets the environment and is called from cron. If your team knows only one of them, that dominates; a Go tool nobody can maintain is worse than a messy script. Neither should hold secrets in code, and both need tests: see testing a Go CLI. The references are the net/http package and the Bash manual.

Also inBash
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.