mise: one static binary for all the tool versions
Every project drags its collection of versions along: repo A wants Node 20, repo B still stands on Node 18, C runs on Python 3.11 while D already demands 3.12 and terraform, hugo and go follow the same law. Installing those versions machine-wide means crushing one on top of the other: the last one wins and every other repo breaks in silence. I took that problem by the right end in 2024 with mise : a per-project tool version manager held by a single Rust binary.
What it is
mise is what asdf should have been from day one: a static, dependency-free binary you drop in the PATH that manages versions per directory. Each repo declares its tools in a versioned file and, when you enter the directory, the declared version activates. Two mechanisms to pick from: shims (one executable per tool in a dedicated directory, consulting the context at execution time) or shell activation (a function that rewrites the PATH on every directory change). Either way the promise is the same: the right tool, the right version, in the right place, no ceremony.
The asdf compatibility is the killer detail: mise reads .tool-versions and the asdf plugin ecosystem stays usable for the exotic tools. Since the core is compiled Rust, version resolution is near-instant and the common tools are handled natively by the core, installed from the projects’ official releases.
Why not asdf
asdf invented the model and I am not spitting on it: it is the grandfather everyone draws from. But the model was born shell-scripted: one plugin per tool, every plugin in bash, every activation walking the plugin stack. On a loaded machine, the prompt paid the bill on every shell start. mise keeps the semantics (a versions file, plugins, shims) and has them executed by a compiled binary: the plugin layer stays available, the common tools no longer go through it and activation costs nothing.
In a repo
The file speaks for itself:
# .tool-versions
node 20.17.0
python 3.12.4
Committed, it follows the repo: a fresh clone runs mise install and lands on the right versions, an npm install for the toolchain. The machine’s defaults live in mise’s global config, the project’s demands in its own file. The rule I apply: everything the CI invokes must be declared in the repo’s versions file, never in the machine’s global state. The file is the contract, the machine is only the execution notebook.
The honest part
In September 2024 the tool is young: it was called rtx last spring (the rename dates from April), releases run fast and sometimes break an edge case, some backends remain temperamental and the documentation is dense to the edge of being a labyrinth. The shim model has its corners: a shim pointing at an uninstalled tool yields an error that does not always say its name. Nothing disqualifying, but this is a tool that moves faster than it documents.
The word at the end
- Tool versions are project data, not machine state. The file declaring them lives in the repo.
- A compiled binary beats a pile of bash scripts for the same semantics. The asdf model, without the startup tax.
.tool-versionscompatibility is the bridge. The asdf ecosystem stays usable; migrate tool by tool.- Test what the migration breaks. A tool rename this fresh brings its share of surprises; be glad they arrive before the CI does.
[edit: 2026] Two years later, mise grew into much more than a version manager: the mise.toml file also declares environment variables and tasks (build, test, lint), Windows support is native, installation goes through scoop or winget and it is what pins Hugo in this blog via the .tool-versions. The Windows side of the story is told in clean package management on Windows
.