Homebrew is the default package manager for macOS, but Nix is a powerful alternative that offers several advantages.

Here’s a quick look at Homebrew in action:

# Install a common utility
brew install htop

# Check for outdated packages
brew outdated

# Upgrade all outdated packages
brew upgrade

Homebrew works by downloading pre-compiled binaries or source code, compiling it if necessary, and then symlinking the installed files into a standard location within /usr/local or /opt/homebrew (for Apple Silicon). This approach is simple and familiar to most Unix users.

However, this simplicity comes with trade-offs. When you run brew upgrade, Homebrew attempts to update all installed packages to their latest versions. If a new version of a dependency breaks compatibility with another package that relies on an older version, you can end up with a broken environment. Homebrew has no built-in mechanism to manage multiple versions of the same package or to guarantee that an upgrade won’t affect other installed software.

Nix, on the other hand, takes a fundamentally different approach based on declarative configuration and isolated environments. Instead of installing packages into a shared location, Nix installs each package into its own unique directory under /nix/store. This directory name is a hash derived from the package’s dependencies and configuration, ensuring that each installed package is immutable and self-contained.

Let’s see Nix in action:

# Install a common utility (nix-shell is used for temporary environments)
nix-shell -p htop

# Install a package permanently
nix-env -iA nixpkgs.htop

# List installed packages
nix-env -q

# Upgrade a specific package
nix-env -uA nixpkgs.htop

Nix’s core concept is the "Nix expression," a functional language used to describe how to build packages. These expressions define dependencies, build steps, and installation instructions. When you ask Nix to install something, it evaluates the Nix expression, builds the package (or downloads a pre-built binary "binary cache"), and places it in the /nix/store.

The real power of Nix lies in its ability to manage environments. You can define an environment with a specific set of packages and their exact versions, and Nix will ensure that environment is reproducible. This means if you or someone else tries to set up the same environment on a different machine (or even your own machine later), Nix will build or download the exact same set of dependencies, guaranteeing that it works the same way.

Here’s a glimpse of how a Nix expression for a simple package might look (simplified):

# default.nix
{ pkgs ? import <nixpkgs> {} }:

pkgs.stdenv.mkDerivation {
  name = "my-hello-world";
  src = ./.; # Assumes a hello.c file in the same directory

  buildPhase = ''
    ${pkgs.gcc}/bin/gcc hello.c -o hello
  '';

  installPhase = ''
    mkdir -p $out/bin
    cp hello $out/bin/
  '';
}

You would then build this with nix-build.

The most surprising true thing about Nix is that it doesn’t "upgrade" packages in the traditional sense. When you tell Nix to install a new version of a package, it doesn’t overwrite the old one. Instead, it builds the new version in a new path in the /nix/store, and then updates your user profile’s symlinks to point to this new path. If something goes wrong, you can easily roll back to the previous version by simply reverting your profile’s generation. This immutability and explicit versioning eliminate dependency hell entirely.

The biggest hurdle for many users is Nix’s learning curve. The Nix expression language is unique, and understanding how Nix manages dependencies and environments takes time. Concepts like derivations, the Nix store, and garbage collection (which reclaims unused packages from the /nix/store) require a shift in thinking compared to traditional package managers.

The next concept you’ll likely grapple with is managing system-wide configurations declaratively using NixOS or Home Manager.

Want structured learning?

Take the full Homebrew course →