A Tour of Package Managers: cargo, opam, CPAN, pip, stack, and friends
How six languages each answer the same question - where does my code's code come from? - and why their answers differ.
Every language eventually faces the same plumbing problem: your program depends on other people's programs, and something has to fetch them, version them, build them, and keep them from fighting. The interesting part is that six different communities reached six different settlements, and each settlement quietly reveals the values of the people who built it.
Rust: cargo, the integrated answer
Rust shipped with cargo almost from the start, and the tight coupling shows. A Cargo.toml declares your dependencies, a Cargo.lock pins the exact resolved graph, and a single binary builds, tests, documents, and publishes. Dependencies come from crates.io, the central registry, and version constraints follow Semantic Versioning with caret semantics by default: serde = "1.0" means "any 1.x at least 1.0".
// Cargo.toml dependency line, then ordinary use
use serde::Serialize;
#[derive(Serialize)]
struct Demo { name: String, version: String }
Cargo's defining choice is that it allows multiple versions of the same crate to coexist in one build, distinguished by their major version. This sidesteps the "diamond dependency" deadlock that plagues single-version-per-process ecosystems, at the cost of occasionally larger binaries. Because the resolver, build system, and registry were designed together, the experience is unusually seamless - the price was paid up front, in language age.
Go: modules and the proxy
Go arrived late to dependency management and lived for years on GOPATH, where your import path was your filesystem path. The modern answer, Go modules, is refreshingly opinionated. A go.mod lists requirements, a go.sum records cryptographic hashes, and the import path is the source URL itself.
import "github.com/google/uuid"
Two ideas distinguish Go. First, Minimal Version Selection: rather than picking the newest compatible version, Go picks the oldest version that still satisfies everyone's stated minimum. Builds are reproducible without a lock file because the algorithm is deterministic by construction. Second, the module proxy and checksum database mean fetches are cached, verified, and resilient even when an upstream repo disappears. The semantic-import-versioning rule - major version 2 and beyond lives at a /v2 suffix in the path - is divisive but honest: an incompatible API gets a new name.
Python: pip, and the long road to lock files
pip installs from PyPI, and for most of Python's life that was the whole story, paired with a requirements.txt of loosely pinned names. The hard part in Python was never fetching - it was isolation. Without virtual environments, every install mutated a single shared site-packages, so projects collided.
import json
data = {"name": "demo", "version": "1.0"}
print(json.dumps(data))
The ecosystem has been consolidating around pyproject.toml as the single declarative manifest (PEP 518 and friends), and a wave of newer tools - Poetry, PDM, and the very fast uv - finally bring real lock files and resolver rigor that pip historically lacked. Python's package management feels like archaeology precisely because it accreted over thirty years: wheels replaced eggs, pyproject.toml is replacing setup.py, and the binary-wheel system quietly solved the genuinely hard problem of shipping compiled C extensions to users who have no compiler.
Perl: CPAN, the elder statesman
CPAN, the Comprehensive Perl Archive Network, predates almost everything else here and set the template the others would echo. Begun in 1995, it pioneered the idea of a single curated archive with mirrors worldwide, automated testing via CPAN Testers, and a culture of uploading even tiny modules.
use strict;
use warnings;
my %deps = (Moose => "2.20", JSON => "4.10");
for my $m (sort keys %deps) {
print "$m => $deps{$m}\n";
}
The classic cpan shell installs straight into your system Perl; the modern habit is cpanm (App::cpanminus) for quiet, zero-config installs, paired with local::lib or Carton for project-local dependency pinning à la a lock file. Larry Wall's line that "easy things should be easy and hard things should be possible" is the whole design ethic here - and CPAN's enormous, battle-tested module count is the dividend of that philosophy compounding for three decades. Its weakness is the flip side: there is no central version-resolution authority, so reproducibility is something you opt into rather than get for free.
OCaml: opam, packages plus switches
opam, the OCaml Package Manager, carries one extra burden the others mostly avoid: OCaml programs are often sensitive to the exact compiler version, so opam manages compilers too. A "switch" is a self-contained sandbox holding both a specific compiler and a coherent set of packages.
(* a package's interface, declared in an .mli *)
val greet : string -> string
This makes opam feel like a hybrid of a package manager and a tool like rbenv or nvm. Its solver is genuinely sophisticated - it leans on real constraint-solving (historically the Cudf/aspcud lineage) to find a consistent assignment across the whole dependency graph, because OCaml's compiled-interface compatibility is strict and a near-miss simply will not link. Builds are typically driven by dune, with opam handling acquisition and the compiler-version dance.
Haskell: stack and cabal, taming the unbuildable
Haskell is pure, lazy, and statically typed, with type classes and monads structuring how effects and abstraction work. None of that touches packaging directly, but Haskell's love of fine-grained abstraction means programs accumulate many small dependencies, and historically resolving a mutually compatible set against the GHC compiler was so painful it earned the nickname "Cabal hell."
module Demo (greet) where
greet :: String -> String
greet name = "hello, " ++ name
Two tools share the field. cabal is the original, and its modern v2/Nix-style builds keep per-project sandboxes that largely cured the old hell. Stack took a different tack: it builds atop Stackage, a curated snapshot of package versions that are known to compile together against a given GHC. You name a resolver - a snapshot like lts-22.x - and Stack guarantees that whole set is mutually consistent, fetching GHC itself if needed. The trade is familiar: curated snapshots buy reproducibility and peace, at the cost of being slightly behind the bleeding edge. (GHC is not installed in this environment, so the snippet above is shown for illustration rather than executed.)
What the differences reveal
Line them up and a spectrum appears. CPAN and early pip trusted the user to assemble a working set; cargo, Stack, and Go modules bake consistency into the tooling so you cannot easily get it wrong. opam and Stack both confront a compiler-coupling problem the dynamic languages never had, and both answer with sandboxing - switches and snapshots respectively. Go and cargo, designed in the lock-file era, treat reproducible builds as table stakes; Perl and Python had to grow that muscle later, around archives never meant to enforce it.
The deepest divergence is the registry model. crates.io, PyPI, and CPAN are single global namespaces; Go deliberately has none, using URLs so anyone's repository is a package without anyone's permission. Stackage curates, PyPI does not. There is no winner here - only a long argument about who you trust to keep the lights on, conducted entirely in the grammar of add, install, and build.