# Nix vs asdf: Which Version Manager Should You Use?

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.

<iframe width="100%" height="315" src="https://www.youtube.com/embed/tKqBLvjka8o" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>


## What is asdf?

![Screenshot of asdf Github page](https://imagedelivery.net/xZXo0QFi-1_4Zimer-T0XQ/6a3034a2-9964-4804-061e-5cb0b0e21600/lg2x =1200x600)

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](https://imagedelivery.net/xZXo0QFi-1_4Zimer-T0XQ/e1a19361-11e4-4ea9-b054-984bceea2700/lg2x =2978x1550)

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:

```command
asdf plugin add nodejs
```

```command
asdf plugin add ruby
```

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

```command
asdf list all nodejs
```

```command
asdf install nodejs 20.10.0
```

```command
asdf install ruby 3.2.2
```

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

```command
asdf global nodejs 20.10.0
```

```command
asdf global ruby 3.2.2
```

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

```command
cd ~/projects/my-app
```

```command
asdf local nodejs 18.18.0
```

```command
asdf local ruby 3.1.4
```

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

```command
cat .tool-versions
```

```text
[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:

```command
asdf current
```

```text
[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`:

```command
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:

```nix
[label 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:

```nix
[label 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:

```command
cd ~/projects/legacy-app
```

```command
cat .tool-versions
```

```text
[output]
nodejs 16.20.0
ruby 2.7.8
```

```command
node --version
```

```text
[output]
v16.20.0
```

Now switch to a different project:

```command
cd ~/projects/modern-app
```

```command
cat .tool-versions
```

```text
[output]
nodejs 20.10.0
ruby 3.2.2
```

```command
node --version
```

```text
[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:

```command
cd ~/projects/legacy-app
```

```command
nix-shell
```

```text
[output]
Node version: v16.20.0
Ruby version: ruby 2.7.8p225
```

```command
node --version
```

```text
[output]
v16.20.0
```

Exit that shell and enter a different project:

```command
exit
```

```command
cd ~/projects/modern-app
```

```command
nix-shell
```

```text
[output]
Node version: v20.10.0
Ruby version: ruby 3.2.2p53
```

```command
node --version
```

```text
[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:

```command
echo "use nix" > .envrc
```

```command
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:

```command
asdf list all nodejs
```

```text
[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:

```command
asdf install nodejs 20.11.0
```

```text
[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:

```command
nix search nixpkgs nodejs
```

```text
[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:

```nix
[label 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:

```nix
[label 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:

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

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

```nix
[label 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:

```command
asdf install nodejs 20.11.0
```

```command
asdf local nodejs 20.11.0
```

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

```command
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:

```command
asdf install nodejs 20.10.0
```

```command
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:

```command
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:

```command
nix-env --list-generations
```

```text
[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:

```command
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:

```command
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:

```command
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:

```text
[label .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:

```command
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:

```nix
[label 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:

```command
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:

```yaml
[label .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:

```yaml
[label .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:

```command
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:

```command
asdf plugin update --all
```

```command
asdf list all nodejs
```

```command
asdf install nodejs 20.11.0
```

```command
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:

```nix
[label 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.
