# mvdan/sh: shfmt, a shell parser and interpreter written in Go

> mvdan/sh bundles a POSIX, Bash, Zsh and mksh parser, the shfmt formatter, and a pure-Go interpreter. It suits Go tooling and CI formatting, and not scripts that need real subshell PIDs.

**mvdan/sh** — A shell parser, formatter, and interpreter with bash and zsh support; includes shfmt

- Repository: https://github.com/mvdan/sh
- Website: https://pkg.go.dev/mvdan.cc/sh/v3
- Stars: 9,097 · Forks: 455
- Language: Go
- License: BSD-3-Clause
- Published: 2026-09-21 · Updated: 2026-09-21 · Language: en
- Canonical page: https://hysenlabs.com/projects/mvdan-sh

## What mvdan/sh solves, and who ends up using it

Shell scripts are usually treated as opaque text by tooling. Editors highlight them with regexes, formatters rewrite them with heuristics, and any attempt to evaluate them means spawning a system shell. mvdan/sh takes the opposite route: it parses shell source into a syntax tree in Go, so a program can inspect, rewrite, or execute a script without a shell binary being present. The README describes the project as "a shell parser, formatter, and interpreter" supporting POSIX Shell, Bash, Zsh, and mksh.

The audience splits in two. The first group writes Go: they need to lint a repository of scripts, expand variables and globs, or run untrusted shell snippets inside a sandbox. The second group never writes Go at all. They install the shfmt binary, point it at a directory of scripts, and use it as the single formatting authority for a team. The README frames that second use explicitly, arguing that "the true value in a formatter is consistency, especially for teams of developers" and that the project does not aim to satisfy every developer's personal preference.

That is a deliberate narrowing. If you are looking for a formatter with a large option matrix, this is not it, and the README says so.

## Three packages, one syntax tree

The repository is split into directories that mirror the three jobs. syntax/ holds the parser and the printer that shfmt uses. shell/ holds one-call helpers with shell semantics: splitting a command line into arguments and quoting it back, expanding $VAR and ~ in strings, and globbing with ** or matching case-style patterns. interp/ runs scripts without a system shell, including on Windows, and exposes handlers that let the caller control what scripts may execute and access.

The data flow for the formatter is the same as for any Go consumer: source text goes into the parser, a syntax tree comes out, and the printer writes it back with the project's canonical style. Because both steps are library calls, shfmt is a thin command over the same code path you would use from your own program. The README points readers at syntax/canonical.sh as a quick look at the default style, and at the syntax package documentation for examples of inspecting and formatting a tree.

The interpreter is where the design gets interesting. The README states that the entire library is written in pure Go, and that this limits how closely the interpreter can follow POSIX Shell and Bash semantics. Go does not support forking its own process, so subshells use a goroutine instead, which means real PIDs and file descriptors cannot be used directly. That single constraint explains a whole class of behaviour differences, and it is worth reading twice before you plan to run production scripts through interp.

## Installing shfmt and formatting a real script

The README gives a single Go install line for the formatter:

```bash
go install mvdan.cc/sh/v3/cmd/shfmt@latest
```

That places shfmt in your Go bin directory. Distribution packages also exist, and the README lists Alpine, Arch, Debian, Docker, Fedora, FreeBSD, Homebrew, MacPorts, NixOS, OpenSUSE, PyPI, Scoop, Snapcraft, Void, and webi as packaging channels, so on macOS the Homebrew formula is the usual route and on Debian or Ubuntu the packaged build avoids needing a Go toolchain at all.

The everyday invocation from the README is the list-and-write pair:

```bash
shfmt -l -w script.sh
```

The -l flag lists files whose formatting differs, and -w writes the result back in place. On a first run against an existing repository, expect a long list of filenames before anything is rewritten; that list is your estimate of how much churn the change will cause in review.

If you would rather see the diff before touching the file, pipe a single script through standard input. The README's own caveat examples use this form, for instance:

```bash
echo '${array[weird!key]}' | shfmt
```

Here shfmt reports a parse error rather than a reformatting, because the unquoted associative array index is ambiguous. That is the normal failure shape: shfmt refuses to guess, and prints a position and a message.

For container use, the README documents a run command that keeps file ownership intact:

```bash
docker run --rm -u "$(id -u):$(id -g)" -v "$PWD:/mnt" -w /mnt my:tag <shfmt arguments>
```

The image only contains shfmt, and the README notes that -alpine variants exist on Alpine Linux. Building your own image is a one-liner the README provides: docker build -t my:tag -f cmd/shfmt/Dockerfile .

## Where the parser deliberately gives up

The caveats section is unusually honest, and it is the best predictor of whether mvdan/sh will fit your codebase. The first caveat concerns Bash associative array indexing. The static parser has to assume an unquoted index is an arithmetic expression, so keys containing spaces or punctuation break. The README demonstrates three cases: ${array[spaced string]} fails with "not a valid arithmetic operator", ${array[weird!key]} fails with "reached `!` without matching `[` with `]`", and ${array[dash-string]} does not fail at all. It silently becomes ${array[dash - string]}, with spaces inserted around the hyphen. That last one is the dangerous case, because the output is valid shell that no longer does what the input did. Quote your array indices.

The second caveat is the $(( and (( ambiguity. The README says backtracking would complicate the parser and make streaming support via io.Reader impossible, so it is simply not supported, and the POSIX spec's advice to space the operands is cited instead. Feeding $((foo); (bar)) to shfmt produces an error rather than a best guess.

The third caveat is that export, let, and declare are parsed as keywords rather than as ordinary commands with word arguments. The README justifies this: it allows statically building their syntax tree and is required to support declare foo=(bar). The cost is that any tooling which treats those three names as arbitrary commands will see a different tree shape than it expects.

The fourth is the pure-Go constraint described earlier. Subshells are goroutines, so real PIDs and file descriptors are unavailable. If your script calls a program that inspects its own parent PID, or passes an inherited file descriptor to a child, the interpreter is the wrong tool and a system shell is the right one.

## The formatter's refusal to add options

The formatting FAQ states two positions that will decide some adoption questions outright. First, the formatter cannot be disabled for ranges of lines. The README explains that most users asking for this are working around a bug or dislike how a piece of code is formatted, and that partial-file formatting brings edge cases the project has no resources or interest in handling. If your team's workflow depends on a comment directive that freezes a hand-aligned block, shfmt will not honour it.

Second, the project avoids adding formatting options. The README's reasoning is that each flag interacts with all others, multiplying the cost of development, maintenance, testing, and documentation. So the option list is short by intent, not by neglect. Compare that with a general-purpose formatter that exposes dozens of switches: the trade is configurability for a smaller surface and fewer combinations to test.

Both positions are consistent with the stated goal of consistency over personal preference. They are also the two most likely reasons a team evaluates shfmt and walks away.

## shfmt versus a general-purpose formatter

The closest alternative in practice is Prettier with prettier-plugin-sh, which the README lists among integrations. That plugin uses sh-syntax, a third-party npm package that bundles this library compiled to WASM. So the parsing is not actually a different implementation; the difference is the surrounding system. Prettier brings a plugin ecosystem, a JavaScript configuration file, and a formatting model shared with your JS, TS, and CSS files. shfmt brings a compiled binary, a manpage, and EditorConfig support through mvdan.cc/editorconfig. If your repository already runs Prettier on everything else, adding it for shell keeps one tool and one config; if you want shell formatted without a Node toolchain in the loop, shfmt is the shorter path.

The README also notes that the project previously maintained an npm package called mvdan-sh built with GopherJS, and that it is now archived given its poor performance and GopherJS being less actively developed. Readers who remember that package should move to sh-syntax instead.

A second comparison point is running scripts. The alternative to interp is the system shell itself. A system shell gives you real process semantics and full Bash behaviour; interp gives you a pure-Go execution path that works on Windows and can be sandboxed through handlers. Those are different goals, not competing implementations of the same one.

## Maintenance, licensing, and what upgrades cost

The repository is not archived, and the last push was on 2026-09-09. Recent releases are v3.14.1 on 2026-09-06, v3.14.0 on 2026-08-28, and v3.13.1 on 2026-04-06. The gap between v3.13.1 and v3.14.0 is roughly five months, so the cadence is not uniform, and the two September releases three weeks apart suggest a fix following a feature release. There is no published support window or long-term release branch in the README.

The licence is BSD-3-Clause, which permits use in closed-source products provided the copyright notice and disclaimer are retained. That matters most for the interp package: embedding it in a commercial product is permitted under those terms. This is not legal advice, and the LICENSE file is the authority.

Upgrade cost depends on which surface you consume. The shfmt binary is the cheapest to move: the README states that options are added reluctantly, so flag churn is low by design. The Go packages are the expensive surface, because the syntax tree is a public API in the syntax package and a change to node types or fields is a compile error in your code. The go.mod pins go 1.26.0, and the README states the project requires Go 1.26 or later, so a toolchain upgrade may be part of a library upgrade. The CHANGELOG.md at the repository root is where release-level changes are recorded.

## Conclusion

Adopt mvdan/sh if you are writing Go tooling that must parse or evaluate shell, or if you want one formatter with no configuration surface across a team. Do not adopt it if your scripts depend on real subshell PIDs, file descriptors, or on suppressing formatting for individual line ranges, since the README states the formatter cannot be disabled for ranges of lines. Before committing, verify the Go toolchain version against the go.mod requirement and check that the shfmt flags you rely on exist in the manpage for your installed release.

## FAQ

### How do I install shfmt from mvdan/sh?

The README gives go install mvdan.cc/sh/v3/cmd/shfmt@latest for the Go route, and lists distribution packages for Alpine, Arch, Debian, Fedora, FreeBSD, Homebrew, MacPorts, NixOS, OpenSUSE, PyPI, Scoop, Snapcraft, Void, and webi.

### What does shfmt do to a script by default?

It parses the script into a syntax tree and prints it back in the project's canonical style. The README's everyday example is shfmt -l -w script.sh, where -l lists files that differ and -w writes the changes in place.

### Can I use mvdan/sh from JavaScript?

The parser and formatter are available as a third-party npm package called sh-syntax, which bundles this library compiled to WASM. The older mvdan-sh package built with GopherJS is archived, and the README directs users to sh-syntax instead.

### Why does shfmt fail on a Bash associative array key?

The static parser assumes an unquoted index is an arithmetic expression. The README shows that ${array[spaced string]} and ${array[weird!key]} produce errors, while ${array[dash-string]} is silently rewritten as ${array[dash - string]}, so quotes are needed.

### Does the mvdan/sh interpreter use real subshells?

No. The README states that the library is pure Go, Go does not support forking its own process, and subshells therefore use a goroutine, which means real PIDs and file descriptors cannot be used directly.

## Sources

- [License: BSD-3-Clause](https://github.com/mvdan/sh/blob/master/LICENSE)
- [mvdan/sh on GitHub](https://github.com/mvdan/sh)
- [Project website](https://pkg.go.dev/mvdan.cc/sh/v3)
- [README](https://github.com/mvdan/sh/blob/master/README.md)
- [Releases](https://github.com/mvdan/sh/releases)

---

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