Clark Kampfe

uv vs. Mix

2026-02-22

Until recently, I've mostly avoided writing Python for both personal and professional work, because the dependency story has been so bad. There are 83 different build tools. They're all bad. Builds aren't repeatable. Dependencies fail to resolve constantly. You somehow never have the correct version of Python installed. If you've done any Python at all, this probably sounds familiar.

Like I said, until recently. But I've been writing more Python lately, because uv doesn't suck. For context, uv is a build tool and package manager for Python, like Ruby's bundler, Rust's cargo, Javascript's npm, or Elixir's mix.

As far as I'm concerned, uv has redeemed Python as a programming platform. I no longer fear having the wrong version of Python installed. My builds are (finally, in the year of our lord 2026) pinned with a lockfile I can check into version control. I can write single-file scripts that not only pin their Python version but list and resolve their dependencies inline! I no longer think about venvs or pip! Dependencies resolve and fetch almost instantaneously.

uv is so good.

I've also done a lot of Elixir in my time, so whenever I'm using uv, I'm comparing it against Elixir's build tool, Mix. Mix is certainly not the worst build tool/package manager I've ever used, but its design is annoying after using uv.

Fetching dependencies

Mix requires me to mix deps.get to manually fetch dependencies. This makes no sense.

I've seen all kinds of people trying to make arguments for why this is good. It's "explicit". It "makes the programmer think about fetching the dependencies". "It works even when you don't have internet". This nonsense papercut makework. This is one of those things that the computer can clearly do for me, and the computer should do for me. uv fetches dependencies when you run something. Tell me if something has gone wrong or requires human intervention, but forcing me to care about a pure implementation detail like fetching dependencies is bad ergonomics. I don't care, I don't want to care.

Language version

uv allows me to pin my project or script to a specific Python version or a constrained range of versions, e.g. >=3.6. It then - get this - installs a suitable Python version to satisfy whatever constraint I describe. Language versions should be a property of the project, and uv encourages this. If I want a version of a language installed on my system globally, that's fine and reasonable, but it's a separate concern from whatever version of that language my project runs. My project shouldn't know about whatever version of the language is installed on the system, and vice versa.

Mix allows the user to specify an Elixir version contraint for the project, but does nothing to help you satisfy this contraint. Why? Why can't Mix download the correct Elixir for my project? Why should installing Elixir depend on the charity and competence of some third-party tool like asdf or mise or homebrew or apt? Why do I have to install Erlang/OTP separately and hope that my package manager has the right version and build for my system's target triple? Shouldn't I be able to say my project requires erlang: "~> 28.0" and Mix just figures out how to make that happen?

Project file

uv uses the Python-standard pyproject.toml. This is similar to Rust's Cargo.toml, and great because it's just TOML. TOML is just a format that anything can read and write, like JSON, so you can trivially write other programs to read the TOML, validate it, modify it if necessary, diff it, etc. Mix uses a mix.exs file, which is an Elixir script file, written in Elixir. This is fine. There is nothing objectively bad about Elixir script files, but they're parochial and only Elixir uses them. Pretty much the only thing that can read Elixir scripts is Elixir. This may not be a problem for your workload, but if you want to have literally any other program or system read your project file (for example, your CI system or some third-party security tool your company requires), it's annoying and possibly deal-breaking.

Tools

uv provides for the installation and execution of what they call tools. Tools are, in uv's definition, "Python packages that provide command-line interfaces." uv handles the downloading and running of these tools and their dependencies, installing them into isolated virtual environments and making them available system-wide with the uvx command.

This is sneakily huge: by providing a way to trivially install and run 3rd-party Python tools, it legitimizes Python as an application language. It says, "hey look at how easy it is to run other people's cool programs that happen to be written in Python."

If someone else has written some tool in Elixir, I have to either run their bespoke installation process (which I don't want to do), or I have to hope they packaged it as an escript and that I have a compatible version of Erlang installed, or I have to hope that they published it to brew or apt or whatever system package manager I'm using (unlikely).

Adding dependencies

With uv, I can uv add requests and uv adds the latest version of requests to my pyproject.toml. 99% of the time when adding a dependency, I want to add the latest version, which uv does by default. If I don't want the latest, I can specify a version like ruff==0.5.0.

With Mix, I have to open mix.exs and type it in by hand. You may say, "well, that's not really a big deal, is it?" And you're right, it's not. But it's a smaller deal to uv add requests.

local.hex, local.rebar

mix local.hex && mix local.rebar are commands you'll see toward the top of the Dockerfile of almost every Elixir project. But what do they do? If you run mix help local.hex, it will tell you, "Installs Hex locally. By default the latest compatible version of Hex will be installed, unless version is specified." What is local.hex? What is local.rebar?

It's one thing for help to not tell me what these are or why they're necessary, but it's another thing entirely to have me add them to my Dockerfile when this is another thing that the computer can do for me. Having these in my Dockerfile (in fact, Phoenix puts these in its generated Dockerfile) is pure noise that distracts from more important information!

If Mix needs a local package index or an Erlang build tool, why should I have to care about that? Doesn't Mix know how to fetch these on its own? If not, why not? Why is my intervention necessary here?

These are pure leaked implementation details that I should not have to care about by default.

uv doesn't make me download a local copy of pypi or create a venv by hand.

Why this is bad design

My biggest gripe with Mix, when compared to uv, is that Mix makes me perform useless toil for no benefit. Having to edit mix.exs by hand in a text editor is easier to mess up than running uv add requests. Having to run and add mix local.hex to a Dockerfile is an implementation detail I shouldn't have to know or care about. Mix not knowing how to install the correct Elixir and Erlang versions makes me have to go make another choice about which version manager to install and learn, and then hope that my chosen version manager can install Elixir and Erlang correctly. Mix has my mix.exs file, why does Mix force me to run mix deps.get to download the packages that I have explicitly told it are necessary to run my project?

This all might sound harsh or petty, but I don't hate Mix! It's in the top half of build tools I've used (way above sbt and gradle and conda). But compared to other recent build tools like uv (and Cargo, from which uv has clearly drawn inspiration) Mix feels poorly designed. It feels creaky and a bit old fashioned. Good design is the art of knowing what the user should and more importantly should not have to care about in order to do what they want to do.

I hope Mix can look at uv, borrow some of its design, and figure out how to jettison some of the useless toil it currently demands of Elixir programmers.

github rss