Back to Linux guides

Nix vs asdf: Which Version Manager Should You Use?

Stanley Ulili
Updated on December 15, 2025

Managing multiple versions of programming languages and tools is one of those problems that seems simple until you actually try to solve it. The version manager you choose determines whether switching between projects feels smooth or like you're constantly fighting with your environment.

asdf has become the go-to solution for developers who work across multiple languages. It replaces language-specific version managers like rbenv, nvm, and pyenv with one tool that handles everything. Install asdf once, and you can manage Ruby, Node.js, Python, and dozens of other tools through the same interface.

Nix takes a fundamentally different approach. Instead of managing versions as a separate concern, it treats them as part of your entire development environment. Think of it as building isolated environments where every dependency, including language versions, is explicitly declared and reproducible.

In this guide, we'll compare both tools, see how they handle version management in practice, and help you figure out which one makes sense for your workflow.

What is asdf?

Screenshot of asdf Github page

asdf solved the problem of having too many version managers. Before asdf, you needed rbenv for Ruby, nvm for Node.js, pyenv for Python, and different tools for every language. Each one had its own commands, configuration files, and quirks.

The genius of asdf is its plugin system. The core tool provides version management mechanics, and plugins add support for specific languages and tools. Want to manage Ruby versions? Install the Ruby plugin. Need Terraform? Install the Terraform plugin. The commands stay the same regardless of what you're managing.

asdf works through shims, which are lightweight wrappers that intercept commands and redirect them to the correct version. When you run ruby, the shim checks your current directory for a .tool-versions file, finds which Ruby version you specified, and runs that version. You never think about it after the initial setup.

What is Nix?

Screenshot of Nix website homepage

Nix doesn't think of itself as a version manager. It's a package manager that happens to solve version management as a side effect of how it works.

The core difference is that Nix builds complete environments. When you specify Python 3.11, you're not just getting Python. You're getting Python 3.11 with its exact dependencies, built in a specific way, stored in a location based on a cryptographic hash of all those inputs. Change any input, and you get a different package.

This happens through Nix's declarative configuration. Instead of running commands to install versions, you write a file describing what your environment needs. Nix reads that file and builds everything to match. Two developers using the same configuration file get byte-for-byte identical environments, even months apart.

The store at /nix/store makes this possible. Every package version lives there with a unique hash. You can have Python 3.9, 3.10, 3.11, and 3.12 all installed simultaneously without any conflict. They're completely separate packages in Nix's eyes.

Comparing asdf and Nix side by side

These tools approach version management from different philosophical starting points. asdf focuses specifically on managing tool versions. Nix treats version management as part of building reproducible environments.

Here's how they compare in actual use:

Feature asdf Nix
Primary purpose Version management Environment management
Configuration .tool-versions per directory shell.nix or flake.nix
Version switching Automatic via shims Automatic via nix-shell
Multiple versions One active per directory All available simultaneously
Language support Plugin-based (500+ plugins) Built into nixpkgs (80,000+ packages)
Global versions Supported with ~/.tool-versions Discouraged, project-specific preferred
Installation speed Medium (compiles if needed) Fast with binary cache
Disk space Moderate Heavy (stores all versions)
Shell integration Required for shims Optional with direnv
Learning curve Gentle Steep
Team adoption Simple .tool-versions file Requires Nix knowledge
CI/CD integration Good Excellent
Reproducibility Version-level Bit-for-bit identical
Dependency isolation No Complete
Rollback support Manual version pinning Built-in generations

Working with asdf commands

asdf follows the pattern of install, configure, and let it manage versions automatically. The commands are consistent across all plugins, which makes the tool predictable once you've learned the basics.

Installing a plugin gives you access to that tool's versions:

 
asdf plugin add nodejs
 
asdf plugin add ruby

Once a plugin is installed, you can list available versions and install the ones you need:

 
asdf list all nodejs
 
asdf install nodejs 20.10.0
 
asdf install ruby 3.2.2

Setting versions happens at two levels. Global versions apply everywhere unless overridden:

 
asdf global nodejs 20.10.0
 
asdf global ruby 3.2.2

Project-specific versions override globals when you're in that directory. You set them with the local command:

 
cd ~/projects/my-app
 
asdf local nodejs 18.18.0
 
asdf local ruby 3.1.4

This creates a .tool-versions file in your project directory:

 
cat .tool-versions
Output
nodejs 18.18.0
ruby 3.1.4

The file is plain text that you commit to version control. When teammates clone the repository and run asdf install, they get the exact versions specified.

Checking which version is currently active is straightforward:

 
asdf current
Output
nodejs          18.18.0         /home/user/projects/my-app/.tool-versions
ruby            3.1.4           /home/user/projects/my-app/.tool-versions
terraform       1.6.3           /home/user/.tool-versions

This shows not just the active version but where that version came from. You can see whether it's from a local project configuration or your global settings.

Now that you've seen asdf's command-based approach, let's look at how Nix handles the same scenarios declaratively.

Setting up environments with Nix

When you use Nix for version management, you're not running install commands. You're declaring what your environment should contain, and Nix builds it for you.

The simplest way to try different versions is with nix-shell:

 
nix-shell -p nodejs-18_x

This drops you into a temporary shell with Node.js 18 available. Exit the shell, and that version is gone from your environment. Nothing permanent changed.

For project-specific environments, you create a shell.nix file that describes everything you need:

shell.nix
{ pkgs ? import <nixpkgs> {} }:

pkgs.mkShell {
  buildInputs = [
    pkgs.nodejs-18_x
    pkgs.ruby_3_1
    pkgs.terraform
  ];

  shellHook = ''
    echo "Node version: $(node --version)"
    echo "Ruby version: $(ruby --version)"
    echo "Terraform version: $(terraform --version)"
  '';
}

Run nix-shell in that directory, and Nix builds an environment with those exact versions. The versions are explicit in the configuration. There's no ambiguity about what "nodejs-18_x" means - it refers to a specific package in nixpkgs.

For more control, you can pin nixpkgs to a specific commit. This locks not just the tool versions but the entire package set:

shell.nix
let
  pkgs = import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/8ad5e8132c5dcf977e308e7bf5517cc6cc0bf7d8.tar.gz") {};
in
pkgs.mkShell {
  buildInputs = [
    pkgs.nodejs-18_x
    pkgs.ruby_3_1
  ];
}

Now the environment is frozen to that nixpkgs commit. Six months from now, running nix-shell will build the same environment with the same versions, even if newer versions exist.

The Nix expression language looks foreign if you're coming from asdf. It's functional, lazy-evaluated, and uses syntax you haven't encountered before. But it's describing your environment as data rather than a sequence of commands.

With the declarative approach clear, let's see how both tools handle switching between projects.

Switching between project versions

asdf makes version switching automatic through shims and directory-based configuration. When you move between projects, asdf detects the .tool-versions file and switches versions without any action from you.

Say you have two projects with different Node.js requirements:

 
cd ~/projects/legacy-app
 
cat .tool-versions
Output
nodejs 16.20.0
ruby 2.7.8
 
node --version
Output
v16.20.0

Now switch to a different project:

 
cd ~/projects/modern-app
 
cat .tool-versions
Output
nodejs 20.10.0
ruby 3.2.2
 
node --version
Output
v20.10.0

The version changed automatically. The shim intercepted the node command, checked for a .tool-versions file, found one, and executed the specified version. You didn't run any commands to make this happen.

Nix handles switching differently because it doesn't use shims. Instead, it modifies your shell's PATH when you enter a nix-shell:

 
cd ~/projects/legacy-app
 
nix-shell
Output
Node version: v16.20.0
Ruby version: ruby 2.7.8p225
 
node --version
Output
v16.20.0

Exit that shell and enter a different project:

 
exit
 
cd ~/projects/modern-app
 
nix-shell
Output
Node version: v20.10.0
Ruby version: ruby 3.2.2p53
 
node --version
Output
v20.10.0

The difference is that Nix requires you to explicitly enter the shell. That extra step gives you more control but feels less automatic than asdf.

This is where direnv integration becomes important for Nix users. With direnv, Nix can match asdf's automatic switching:

 
echo "use nix" > .envrc
 
direnv allow

Now entering the directory automatically loads the nix-shell environment. Leave the directory, and the environment unloads. This gives you asdf-style convenience with Nix's reproducibility.

Automatic switching is convenient, but knowing what versions are available matters too.

Discovering and installing new versions

asdf makes finding new versions straightforward. Each plugin maintains its own list of available versions:

 
asdf list all nodejs
Output
...
18.18.0
18.18.1
18.18.2
18.19.0
20.9.0
20.10.0
20.11.0
21.0.0
21.1.0

The list shows every version the plugin knows about. Installing is one command:

 
asdf install nodejs 20.11.0
Output
Downloading nodejs 20.11.0...
Installing nodejs 20.11.0...
Installed nodejs 20.11.0 to /home/user/.asdf/installs/nodejs/20.11.0

Some plugins compile from source, which can take time. The Ruby plugin, for example, compiles Ruby from source unless you configure it to use precompiled binaries. This means the first install of a version might take 5-10 minutes.

Nix takes a different approach to discovery. You search nixpkgs, which is the repository of all Nix packages:

 
nix search nixpkgs nodejs
Output
* legacyPackages.x86_64-darwin.nodejs-16_x (16.20.2)
  Event-driven I/O framework for the V8 JavaScript engine

* legacyPackages.x86_64-darwin.nodejs-18_x (18.18.2)
  Event-driven I/O framework for the V8 JavaScript engine

* legacyPackages.x86_64-darwin.nodejs-20_x (20.10.0)
  Event-driven I/O framework for the V8 JavaScript engine

Nix doesn't show every minor version because it doesn't maintain them all. nixpkgs includes the latest version of each major release. If you need Node 18, you use nodejs-18_x, which gives you the latest 18.x version in that nixpkgs snapshot.

For very specific versions, you can reference older nixpkgs commits:

shell.nix
let
  # nixpkgs commit that had nodejs 18.16.0
  oldPkgs = import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/specific-commit-hash.tar.gz") {};
in
pkgs.mkShell {
  buildInputs = [
    oldPkgs.nodejs-18_x
  ];
}

This gives you precise version control at the cost of needing to find the right nixpkgs commit. Tools like nixhub.io help search for packages across different nixpkgs snapshots.

The installation experience differs too. Nix checks for binary caches first. If a prebuilt binary exists, installation takes seconds. If not, Nix compiles from source, but with better parallelization than asdf.

Knowing what's available is useful, but handling dependencies around those versions matters just as much.

Managing dependencies and isolation

asdf manages tool versions but doesn't isolate their dependencies. When you install Ruby 3.2.2 with asdf, you get Ruby itself. Gems install into that Ruby version's directory, shared across all projects using that Ruby version.

This means two projects using Ruby 3.2.2 share the same gem installations. If project A installs rails 7.0 and project B installs rails 7.1, they both use the same Ruby but different Rails versions. Bundler handles gem isolation, but it's a separate layer on top of asdf.

The same pattern repeats for other languages. Node.js projects use npm or yarn for package isolation. Python projects use virtualenv or pipenv. asdf handles the language version. Other tools handle dependency isolation.

This works fine but creates a stack of tools. You're running asdf for version management, bundler for Ruby gems, npm for Node packages, and pip for Python packages. Each tool has its own commands and configuration files.

Nix treats dependencies as first-class concerns. When you specify a package in Nix, you're specifying its entire dependency tree:

shell.nix
{ pkgs ? import <nixpkgs> {} }:

pkgs.mkShell {
  buildInputs = [
    pkgs.ruby_3_2
    pkgs.rubyPackages_3_2.rails
    pkgs.nodejs-18_x
    pkgs.nodePackages.typescript
  ];
}

This environment includes Ruby 3.2, Rails from nixpkgs, Node.js 18, and TypeScript. Each package includes its dependencies. Rails gets the exact gems it needs. TypeScript gets the exact Node modules it requires. Everything is isolated in /nix/store with unique hashes.

Two different projects can use the same Ruby version but completely different gem sets without conflict:

project-a/shell.nix
{ pkgs ? import <nixpkgs> {} }:

pkgs.mkShell {
  buildInputs = [
    pkgs.ruby_3_2
    pkgs.rubyPackages_3_2.rails_7_0
  ];
}
project-b/shell.nix
{ pkgs ? import <nixpkgs> {} }:

pkgs.mkShell {
  buildInputs = [
    pkgs.ruby_3_2
    pkgs.rubyPackages_3_2.rails_7_1
  ];
}

Each project gets its own isolated environment. No shared state. No dependency conflicts. No additional tools needed for isolation.

The tradeoff is that Nix's package repository might not have every gem, npm package, or pip package you need. Popular ones are there, but obscure dependencies might not be packaged. You can package them yourself, but that requires learning Nix's packaging system.

Isolation is important, but knowing you can recover from mistakes matters too.

Handling upgrades and rollbacks

asdf makes upgrading explicit. You install new versions manually and switch to them when you're ready:

 
asdf install nodejs 20.11.0
 
asdf local nodejs 20.11.0

If the upgrade breaks something, you switch back to the old version:

 
asdf local nodejs 20.10.0

This works as long as you still have the old version installed. If you've uninstalled it, you need to reinstall:

 
asdf install nodejs 20.10.0
 
asdf local nodejs 20.10.0

The rollback is manual but straightforward. You're explicitly choosing which version to use, so you know exactly what's happening.

Removing old versions is also manual:

 
asdf uninstall nodejs 18.18.0

This deletes the version from disk. If you need it later, you reinstall it from scratch.

Nix treats every environment change as atomic. When you modify shell.nix and run nix-shell, Nix builds a new environment. The old environment still exists in /nix/store. Nothing gets deleted.

You can see all environment generations:

 
nix-env --list-generations
Output
   1   2024-11-10 14:22:15
   2   2024-11-18 09:45:33
   3   2024-11-27 16:10:47 (current)

Rolling back is instantaneous:

 
nix-env --rollback

You're now on generation 2. All packages from generation 3 are gone from your environment, but they're still in /nix/store. If the rollback was a mistake, you can jump forward:

 
nix-env --switch-generation 3

This safety net makes experimentation cheap. Want to try Ruby 3.3? Modify your shell.nix, run nix-shell, and test it. Doesn't work? Delete the change and run nix-shell again. You're back to the old environment in seconds.

The downside is disk space. Nix keeps everything until you explicitly run garbage collection:

 
nix-collect-garbage -d

This deletes all old generations and their unused packages. You can also set up automatic garbage collection to run periodically.

Rollback capabilities are great, but how these tools work with teams is often more important.

Team adoption and environment sharing

asdf works well for teams because the configuration is simple. You commit a .tool-versions file to your repository:

.tool-versions
nodejs 20.10.0
ruby 3.2.2
terraform 1.6.3
postgres 15.5

New team members install asdf, install the plugins, and run:

 
asdf install

This reads .tool-versions and installs everything listed. They get the right versions without hunting through documentation.

The weakness is that .tool-versions only specifies tool versions, not their dependencies or how they're built. Two people might have Node 20.10.0 but compiled with different options or linked against different libraries. This rarely causes issues, but when it does, it's frustrating to debug.

You also can't specify system dependencies in .tool-versions. If your project needs libxml2 or postgresql development headers, you document that separately. Team members install those through their system package manager, which means different versions across different operating systems.

Nix configuration captures everything. A shell.nix file defines the complete environment:

shell.nix
{ pkgs ? import <nixpkgs> {} }:

pkgs.mkShell {
  buildInputs = [
    pkgs.nodejs-20_x
    pkgs.ruby_3_2
    pkgs.postgresql_15
    pkgs.libxml2
    pkgs.terraform
  ];

  shellHook = ''
    export DATABASE_URL="postgresql://localhost/myapp_dev"
    export NODE_ENV="development"
  '';
}

This includes not just language versions but system libraries, databases, and environment variables. Commit this file, and every team member gets an identical environment:

 
nix-shell

The challenge is that everyone needs Nix installed and some understanding of how it works. asdf's barrier to entry is lower - you install plugins and run commands. Nix requires grasping its philosophy first.

For teams already using Nix, environment sharing is bulletproof. For teams new to Nix, the learning curve can slow down adoption.

Team environments are important, but CI/CD systems need the same reliability.

Integration with continuous integration

asdf works in CI pipelines with some setup. You install asdf, install plugins, and then install versions:

.github/workflows/test.yml
name: Test
on: [push]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Install asdf
        uses: asdf-vm/actions/setup@v2

      - name: Install asdf plugins
        run: |
          asdf plugin add nodejs
          asdf plugin add ruby

      - name: Install versions
        run: asdf install

      - name: Run tests
        run: |
          npm install
          npm test

This works but adds overhead. Every CI run installs asdf and tools from scratch. Caching helps but doesn't eliminate the installation time.

The bigger issue is reproducibility. If nixpkgs updates or an asdf plugin changes, your CI environment might drift from what developers use locally. The .tool-versions file pins versions, but not the entire dependency tree.

Nix shines in CI because of binary caches and complete reproducibility. The same shell.nix that works locally works in CI:

.github/workflows/test.yml
name: Test
on: [push]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - uses: cachix/install-nix-action@v24

      - uses: cachix/cachix-action@v12
        with:
          name: myproject
          authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'

      - name: Run tests
        run: nix-shell --run "npm test"

The binary cache means packages download in seconds instead of minutes. If you've built this environment before, CI pulls the cached result.

You can also build and test in pure Nix environments that guarantee no system dependencies leak in:

 
nix-shell --pure --run "npm test"

This runs tests in an environment containing only what's declared in shell.nix. If tests pass here but fail on someone's machine, you know their local environment has extra dependencies that aren't captured in configuration.

CI integration favors Nix heavily, but what about the everyday experience of using these tools?

Learning curves and daily workflows

asdf gets out of your way quickly. The commands are intuitive and consistent across plugins. You learn install, local, global, and current, and that covers 90% of usage.

The mental model is simple: asdf manages versions, and .tool-versions files specify which versions to use in which directories. When you run a command, asdf's shim finds the right version and executes it. That's the entire system.

You can be productive with asdf in an hour. Install it, add some plugins, set versions, and you're done. The tool becomes invisible after the initial setup.

Daily workflow with asdf means occasionally running updates:

 
asdf plugin update --all
 
asdf list all nodejs
 
asdf install nodejs 20.11.0
 
asdf local nodejs 20.11.0

That's about it. Most of the time, asdf just works in the background.

Nix demands more upfront investment. The expression language is unfamiliar. The concepts of stores, derivations, and profiles feel abstract. The documentation is comprehensive but assumes knowledge you're still building.

Your first week with Nix involves confusion. Why does every package have a hash? Why do I need to write a function to define an environment? Why is there a /nix/store directory with weird paths?

But once the concepts click, Nix becomes powerful in ways asdf can't match. You start writing shell.nix files for every project. You pin nixpkgs commits for reproducibility. You build custom packages when nixpkgs doesn't have what you need.

The daily workflow with Nix involves more configuration writing:

shell.nix
{ pkgs ? import <nixpkgs> {} }:

pkgs.mkShell {
  buildInputs = [
    pkgs.nodejs-20_x
    pkgs.yarn
    pkgs.postgresql_15
  ];
}

But less command running. Enter the directory, run nix-shell, and you have everything. Update nixpkgs when you want newer versions. Roll back when updates break things.

The Nix workflow is more declarative and less imperative. You describe what you want rather than running commands to make changes. This feels strange initially but becomes natural over time.

With daily usage patterns clear, let's talk about when to actually use each tool.

Final thoughts

This article looked at asdf and Nix as two ways to manage versions, coming from different philosophies.

If you want simple, fast language version management, asdf is usually the best fit for most teams. If you need fully reproducible, identical environments, especially for complex projects or reliable CI/CD, Nix is the stronger choice, even with a steeper learning curve.

Some people use both. asdf for daily work and Nix for projects where reproducibility matters most.

Got an article suggestion? Let us know
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.