# envsubst: shell style variable substitution, as a CLI and as a Go library

> envsubst is a small Go implementation of the variable expansion that shells do for you, shipped as a command line filter that can fail loudly when a variable is unset or empty, and as a library with three entry points. It carries no dependencies, and the release automation that produces its binaries for five platform and architecture combinations is documented step by step.

**a8m/envsubst** — Environment variables substitution for Go

- Repository: https://github.com/a8m/envsubst
- Stars: 910 · Forks: 98
- Language: Go
- License: MIT
- Published: 2026-10-20 · Updated: 2026-10-20 · Language: en
- Canonical page: https://hysenlabs.com/projects/a8m-envsubst

## The expansion shells do, without a shell

envsubst is environment variables substitution for Go. It reads a template, replaces the variable references it finds with values from the environment, and writes the result out.

The reason this exists as a separate tool is worth stating plainly. Go's standard library has os.ExpandEnv, which does the same job in one line, and the README points at it directly under a see also heading with the note that it only supports the $var and ${var} notations.

So the library is for the cases where the standard library is not enough, and the command line tool is for the cases where you want the behaviour of a shell without invoking a shell.

The differences are in the expression table. envsubst supports the full set of shell parameter expansions: defaults with a dash and a colon, assignment forms with equals and a colon, and the alternative forms with a plus, plus the $$var escape. os.ExpandEnv handles only the plain forms, and it cannot express the difference between a variable that is unset and one that is set but empty.

That distinction is the practical heart of the tool. A template that references a password wants to fail if the password is missing, and a template that references an optional label wants an empty string rather than a failure, and telling those two apart requires the colon forms.

The module is github.com/a8m/envsubst and its go.mod requires Go 1.25.5 and lists no dependencies at all, so the whole thing is standard library.

## Three ways to install it

The installation section covers binaries and the Go toolchain.

For Linux and macOS there are prebuilt binaries for 64 bit systems, downloaded and moved into place with three commands.

```console
curl -L https://github.com/a8m/envsubst/releases/download/v1.2.0/envsubst-`uname -s`-`uname -m` -o envsubst
chmod +x envsubst
sudo mv envsubst /usr/local/bin
```

The backticks around uname are the reason the asset name works on both platforms, since the release assets are named after the operating system and the architecture. On Windows you download envsubst.exe from the releases page, or use curl if you have it.

```console
curl -L https://github.com/a8m/envsubst/releases/download/v1.2.0/envsubst.exe
```

And if you have Go installed, the tool is a go get away.

```console
go get github.com/a8m/envsubst/cmd/envsubst
```

The path ends in cmd/envsubst because the command lives in a separate package from the library, which is the standard Go layout for a project that ships both. The library part is the module root, and the command is a thin wrapper around it.

## The command line is a filter, not a subcommand

Usage is a filter, so it composes with everything else in a shell pipeline.

```sh
envsubst < input.tmpl > output.text
echo 'welcome $HOME ${USER:=a8m}' | envsubst
envsubst -help
```

The first line is the templating use case, reading a template file and writing a rendered file. The second is the substitution on a string, and it also shows the default assignment form at work, where ${USER:=a8m} sets a fallback if the variable is unset or empty. The third prints the options.

There is no subcommand structure and no positional arguments for the template. Everything is a flag, and the two that matter most for a pipeline are the input and output files, which default to standard input and standard output. That default is why the tool behaves like every other filter in a pipeline, and why you can use it without any flags at all when you are reading from a pipe.

The flag table is short and complete: -i for the input file, -o for the output file, and the four behavioural flags described next. The type column shows that the file options take a string or standard input and standard output, and the default column shows the standard stream defaults.

## The restriction flags are the real feature

There are three command line flags that cause the substitution to stop with an error code when their restriction is not met, and the README frames the purpose directly: it is handy if you want to avoid creating configuration files with unset or empty parameters.

The flags are -no-unset, which fails if a variable is not set, and -no-empty, which fails if a variable is set but empty. The third is -no-digit, which stops variables starting with a digit from being replaced, for cases like $1 and ${1} where the text is a positional parameter reference rather than a variable name.

The fourth flag, -fail-fast, changes how the errors are reported. Without it, a run that violates a restriction returns a list of errors, which is useful because you see everything that is wrong in one pass. With it, the run stops at the first error, and the README describes that as a faster feedback loop, which is especially useful when running through a large file or byte array input.

There is a dependency between them that the documentation states: if -fail-fast is set in conjunction with either -no-unset or -no-empty, or both, you get the early exit. If those flags were not specified, -fail-fast is ignored. In other words, -fail-fast on its own does nothing, because there is no error to fail fast on.

The flags combine into tighter restrictions, which is the intended way to use them. A configuration template that must not be rendered with a blank database host is -no-empty, and one that must not be rendered with a missing secret is -no-unset, and both together with -fail-fast stops at the first problem in a long file.

## The expression table, which is the actual specification

The docs table lists the supported expressions, and it is the part to read if you are deciding whether this tool fits your template syntax.

The plain form, ${var}, is the value of var, the same as $var. Then there are four default forms. ${var-$DEFAULT} applies the default if var is not set, and ${var:-$DEFAULT} applies it if var is not set or is empty. The equals forms, ${var=$DEFAULT} and ${var:=$DEFAULT}, have the same conditions but assign the value to the variable as well.

Then there are the alternative forms, ${var+$OTHER} and ${var:+$OTHER}, which evaluate to $OTHER if var is set and to an empty string otherwise. The colon variant tests for set and non-empty.

The escape, $$var, produces a literal $var, which is what you need in a template that contains dollar signs meant for something else, such as a shell snippet that the generated file will later be sourced.

The README notes that most of the rows in the table were taken from a reference card, which is a good sign about how the semantics are meant to behave, since they are the shell semantics rather than something invented for this tool.

One caveat about the parsing worth remembering: the tool works on text, and a dollar sign followed by a digit is treated as a positional parameter reference unless you pass -no-digit. That flag is the difference between a template that can contain $1 literally and one that cannot.

## Three library entry points

The library API is three functions over the three shapes of input.

```go
package main

import (
	"fmt"
	"github.com/a8m/envsubst"
)

func main() {
    input := "welcome $HOME"
    str, err := envsubst.String(input)
    // ...
    buf, err := envsubst.Bytes([]byte(input))
    // ...
    buf, err := envsubst.ReadFile("filename")
}
```

envsubst.String takes a string and returns a string, envsubst.Bytes takes a byte slice and returns one, and envsubst.ReadFile takes a filename and returns the expanded content. All three return an error, which is where the restriction behaviour surfaces when you use the library rather than the command.

The repository layout matches the API. envsubst.go and envsubst_test.go sit at the root, the parse directory holds the expression parser, and the cmd directory holds the command line entry point. There is a testdata directory for fixtures and an _example directory with a runnable main program, which the README points at as the place to look for the programmatic usage.

Two things are missing from the standard library comparison, and both matter. The error return is what lets a caller decide what to do about an unset variable, rather than getting an empty string and finding out later. And the expression set is wider, so a template written for envsubst can use the default and alternative forms that os.ExpandEnv cannot express.

## Release automation, the platform matrix and the tag cadence

The README documents the release process in unusual detail, which is useful if you maintain a fork or need to cut a release yourself.

There are two workflows. create-release.yml is triggered manually from the GitHub Actions interface and takes a tag name plus optional title and description. It creates the git tag, skipping it if it already exists, creates the GitHub release, and handles an existing release gracefully, which means the workflow is rerunnable. binaries.yml is triggered automatically when a release is created, or manually with an optional tag name, and it builds and uploads the binaries.

The procedure is eleven steps, but it is two button presses and two tag names. You run Create Release with a tag following semantic versioning, for example v1.4.5, then run Release Binaries with the same tag.

The platform matrix covers five targets: Linux on AMD64 and ARM64, macOS on Intel and Apple Silicon, and Windows on AMD64. The assets are named envsubst-Linux-x86_64, envsubst-Linux-arm64, envsubst-Darwin-x86_64, envsubst-Darwin-arm64, and envsubst for Windows. Reruns are explicitly supported, including rebuilding the binaries for an existing release.

The release cadence tells a different story from the workflow automation. The tagged releases are v1.4.3 from 2025-02-26, v1.4.2 from 2023-02-11 and v1.4.1 from 2023-02-08, so there is a two year gap between the newest tag and the one before it, while the last push was on 2026-10-18. The project is still being worked on and the release tooling is still being refined, but a version that was current for two years is the one most systems would have installed, and that is worth checking before you pin anything.

The licence is MIT, and the API documentation is on godoc.

## Conclusion

Adopt envsubst when you generate configuration files from templates and you want the shell semantics your operators already know, including defaults and the difference between an unset variable and an empty one, and when you want a CI step that fails instead of writing a file full of blank values. Do not adopt it if you need shell features beyond variable expansion, since there is no command substitution or arithmetic here. Before the first use, read the flag table rather than assuming defaults, because -no-unset and -no-empty are off by default and are the flags that actually protect a generated configuration, and remember that -fail-fast is ignored unless one of them is set.

## FAQ

### What does envsubst do and how is it different from os.ExpandEnv?

envsubst performs environment variable substitution with the shell expression set, including defaults such as ${var:-default}, the alternative forms ${var:+other} and the $$var escape. The README notes that os.ExpandEnv only supports the $var and ${var} notations.

### How do I install envsubst?

Prebuilt binaries for 64 bit Linux and macOS are available from the GitHub releases and can be fetched with curl, moved into /usr/local/bin, and the Windows binary is envsubst.exe. If you have Go installed, go get github.com/a8m/envsubst/cmd/envsubst installs the command.

### How do I make envsubst fail when a variable is missing?

Use the -no-unset flag to fail if a variable is not set, and -no-empty to fail if a variable is set but empty. Add -fail-fast to stop at the first error instead of returning a list, which is useful on large inputs, and note that -fail-fast is ignored unless one of the other two flags is set.

### Which shell expressions does envsubst support?

The documented set covers ${var}, ${var-$DEFAULT}, ${var:-$DEFAULT}, ${var=$DEFAULT}, ${var:=$DEFAULT}, ${var+$OTHER}, ${var:+$OTHER} and the $$var escape. The documentation notes that most of these rows follow the shell reference card semantics.

### Can I use envsubst as a Go library?

Yes. The API exposes envsubst.String for strings, envsubst.Bytes for byte slices and envsubst.ReadFile for files, each returning the expanded value and an error, with a runnable example in the _example directory of the repository.

## Sources

- [a8m/envsubst on GitHub](https://github.com/a8m/envsubst)
- [Issues](https://github.com/a8m/envsubst/issues)
- [License: MIT](https://github.com/a8m/envsubst/blob/master/LICENSE)
- [README](https://github.com/a8m/envsubst/blob/master/README.md)
- [Releases](https://github.com/a8m/envsubst/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/a8m-envsubst
