Skip to content
shellhacks.dev
Go

Why Go Beats Bash for Long Lived Tools and Where It Does Not

Why Go beats Bash for long lived tools, where it does not, and a decision guide on size, testing, dependencies, speed to write and team skills.

6 min read
Summarize in AI
Illustration for "Why Go Beats Bash for Long Lived Tools and Where It Does Not": a shell script in an editor, a loop arrow, a request travelling to a server.

Everyone has a shell script that started as ten lines and now has functions, flags and a comment saying "do not touch". At some point rewriting it in a typed language is cheaper than the next bug. This post gives the criteria, and it is the broader view behind the head-to-head in Go versus Bash for automation. The comparisons assume Bash 5.2 and Go 1.23.

What Go gives you

Go
package main

import (
	"context"
	"fmt"
	"net/http"
	"os"
	"sync"
	"time"
)

func check(ctx context.Context, url string) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return err
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("%s: status %d", url, resp.StatusCode)
	}
	return nil
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	var wg sync.WaitGroup
	failed := false
	var mu sync.Mutex
	for _, u := range os.Args[1:] {
		wg.Add(1)
		go func() {
			defer wg.Done()
			if err := check(ctx, u); err != nil {
				fmt.Fprintln(os.Stderr, err)
				mu.Lock()
				failed = true
				mu.Unlock()
			}
		}()
	}
	wg.Wait()
	if failed {
		os.Exit(1)
	}
}

That checks any number of URLs in parallel with a shared timeout and a clear exit status. In Bash the same thing needs background jobs, wait, temporary files for results and care with quoting. The Go version is longer but has types, explicit errors, real timeouts and cancellation, and it compiles to one file you can copy to any machine of the same platform.

What Bash gives you

Shell
for url in "$@"; do
  curl -fsS --max-time 5 -o /dev/null "$url" || echo "failed: $url" >&2
done

Four lines, no build, readable by every sysadmin and able to use the whole ecosystem of command line tools. For gluing curl, jq, rsync and systemctl together, nothing is faster to write. Bash is also its own interactive environment: you can run and refine a pipeline in the terminal and paste it into a file.

Signs it is time to rewrite

Move to Go when the script needs data structures beyond flat arrays, parses JSON or other formats with more than a couple of fields, retries with backoff, runs work concurrently, or accepts many flags with validation. Also when you cannot test it without mocking five commands, when it must run where the dependencies (jq, a particular awk) are missing, or when it will live for years with several maintainers. The rule of thumb that works: if you are working around the language, switch. Lines of code matter less than how much of it is defensive.

Costs of Go

Shell
go build -o mytool .
GOOS=linux GOARCH=arm64 go build -o mytool-arm64 .

You need a toolchain to build, a place to publish binaries for each platform and a way to version them; tools such as GoReleaser help. The learning curve for a team of sysadmins is real, and a small change needs a rebuild instead of an edit in place. Compare the alternatives in Go, Rust and Python for CLIs too. Shelling out to external commands from Go removes much of the benefit, so prefer libraries when they exist; see os/exec in Go for when you must.

The honest costs of a shell script that grew

The trouble with a long shell script is not the length. It is that the language gives no help in noticing a mistake. A misspelt variable expands to an empty string, a failed command in the middle of a pipeline is ignored without pipefail, and a filename with a space splits into two arguments if one quote is missing. Each of these is avoidable with discipline, and each needs a person to remember it every time. A compiled language moves the same checks to the build: an unused variable is an error, a type mismatch is an error and an unhandled error return is conspicuous in review.

The second cost is testing. Testing a Bash function that calls five external commands means replacing those commands with stubs through the path or with function overrides, and the stubs drift from the real programs. A Go function that takes an interface or a plain value is covered by a table of inputs and outputs, no process spawning involved, and the test runs in milliseconds. The effort of writing tests for a shell script is often what stops people doing it, so the script stays untested and changes become frightening.

What a rewrite really takes

Plan on the Go version being three to five times longer in lines than the shell original, and on taking longer to write the first time. Most of the extra lines are explicit error handling and argument parsing, which the shell version handled by omission, and the omission was often a latent bug. Rewrite in stages instead of all at once: start by moving the part that hurts most, for example JSON parsing or retry logic, into a small Go program that the script calls, and leave the glue in shell. The script gets safer immediately, and the next piece moves when it is the next source of trouble.

Keep the old script working until the new tool has run in production for a while. Compare outputs on real inputs, and have both write to separate locations for a week. A rewrite that changes behaviour silently, such as how it treats an empty line or a trailing slash, produces incidents that the old script never had, and those are costly in a way that no language feature compensates for.

Operating the result

A Go binary is easy to distribute and harder to inspect than a script. Nobody can read the running file to see what it does, so ship the source, tag releases and embed the version in the binary with a linker flag so tool --version answers which build is installed. Add a small structured log of what the tool did, since the shell habit of reading the script to understand behaviour no longer applies.

Think about who maintains it. A team where everyone knows Bash and one person knows Go has just made that person a dependency. If the tool is critical, either invest in teaching a second maintainer or keep the shell version. The most reliable systems here are not the ones written in the best language, but the ones whose maintainers can fix them at three in the morning, which is a statement about people, not syntax.

A decision checklist

When the choice is unclear, answer five questions about the script. How long will it live: a script that runs for a month is not worth a rewrite, while one that will run for five years is. How many people will edit it, and do they know the language. What does it process: flat text suits the shell, nested data suits a typed language. How bad is a silent failure, since a backup script that does nothing and exits zero is a different risk from a formatting helper. And where does it run, because a machine without a toolchain or without jq narrows the choice immediately.

If most answers point to a short life, a single author and low stakes, keep the shell script and tighten it with strict mode and ShellCheck. If most point the other way, begin the rewrite with the part that has the most branches, and keep a record of the commands the old script ran so the new tool can be compared with it on real input.

Where this does not apply

For a one-off migration, a cron wrapper or a bootstrap script, Bash is the right tool and a rewrite is waste. Python hits a middle ground when the target machines already have it. And a Go tool needs maintenance like any program: dependency updates, security fixes and a repository. See the Effective Go guide and the Bash manual for the strengths of each.

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