· 5 min read ·

Shipping a CLI Tool: The Distribution Problem Python Never Solved

Source: lobsters

A post on smiling.dev describing a switch from Python to Rust for a CLI tool made the rounds on Lobsters recently. The author’s experience matches a pattern I’ve seen repeatedly: Python is great for writing the tool, and genuinely painful for shipping it. The Rust angle gets covered in terms of performance and type safety, but the more interesting story is the distribution problem, which is where Python consistently falls apart and Rust consistently doesn’t.

What “Distribution” Actually Means for a CLI Tool

When you write a web service, distribution is someone else’s problem. You containerize it, your orchestrator runs it, and your users never touch the runtime. CLI tools are different. Your users are developers, sysadmins, or machines running in CI pipelines. They need to run your binary on hardware you don’t control, with software environments you can’t predict, without having to understand your dependency graph.

For a CLI tool, a good distribution story means: one artifact, no prerequisites, works on a clean machine. Rust delivers this by default. Python requires you to build it yourself, and none of the available approaches are good.

Python’s Distribution Landscape

Python has several tools aimed at producing distributable CLI applications, and each one has a significant catch.

PyInstaller is the most established option. It bundles your Python interpreter and all dependencies into a single directory or a self-extracting archive. The output looks like a single executable but it isn’t: the archive unpacks to a temp directory at startup, which adds 200-500ms of cold-start latency before your code runs a single line. The resulting “binary” is also enormous, commonly 30-80MB for a modest tool, because you’re bundling CPython itself. Cross-compilation is not supported; you must build on the target platform.

shiv and zipapp take a different approach: they produce a .pyz file, a ZIP archive that Python can execute directly. This is cleaner than PyInstaller for controlled environments, but it requires Python to already be installed on the target machine. For developer tools distributed to the general public, that’s a real constraint.

uv, the Rust-based Python package manager, has improved the installation UX considerably with uv tool install. But this solves installation UX, not the distribution artifact problem. The user still needs uv, which means they need a way to get uv, and you’re back to bootstrapping.

The fundamental issue is that Python’s runtime is a dependency your tool carries everywhere, and there’s no clean way to make it disappear.

A minimal Python CLI using Typer looks like this:

# tool.py
import typer

app = typer.Typer()

@app.command()
def process(
    path: str = typer.Argument(..., help="Path to process"),
    verbose: bool = typer.Option(False, "--verbose", "-v"),
):
    if verbose:
        typer.echo(f"Processing {path}")
    # ... do work

if __name__ == "__main__":
    app()

The code is clean. Typer’s ergonomics are genuinely good. But pyinstaller tool.py --onefile produces a 28MB executable on a clean virtualenv, and that executable unpacks itself to /tmp on every invocation.

What Rust Gives You Instead

A Rust CLI using clap 4.x compiles to a native binary with no runtime dependency. The output is one file. It runs anywhere the target architecture matches. Cross-compilation to Linux from macOS, or to ARM from x86, is a cargo build --target invocation with the right toolchain installed.

use clap::Parser;

#[derive(Parser)]
#[command(name = "tool", about = "Process files")]
struct Args {
    /// Path to process
    path: String,

    /// Enable verbose output
    #[arg(short, long)]
    verbose: bool,
}

fn main() {
    let args = Args::parse();
    if args.verbose {
        println!("Processing {}", args.path);
    }
    // ... do work
}

The binary produced by cargo build --release on a tool this size is around 400KB before stripping. After strip and with opt-level = "z" in your Cargo.toml, you can get under 200KB. That’s not meaningful on a fast connection, but it matters for embedded targets and for CI caches where artifact size affects layer invalidation.

Startup time is the more user-visible number. A Python script with no imports starts in roughly 30-50ms on a warm machine. Add Typer, which pulls in click and rich, and you’re at 80-150ms. After PyInstaller wraps it, add another 200-500ms for the unpack phase. A compiled Rust binary with clap starts in 2-5ms. For a tool invoked hundreds of times in a build pipeline, that accumulates.

The Ecosystem Is Not a Concession

clap 4.x handles argument parsing with derive macros that are as ergonomic as anything in Python. indicatif provides progress bars with more configurability than rich’s equivalent. dialoguer covers interactive prompts. console handles terminal colors and styling. These aren’t second-best alternatives; they’re the libraries used by production tools that millions of developers run daily.

The evidence is the tools themselves. ripgrep, fd, bat, eza, starship, bottom, and dust are all Rust, all distributed as single binaries, and all have install instructions that amount to “download this file and put it in your PATH.”

Compare that to installing a Python CLI tool from GitHub. You clone the repo, create a virtualenv, install dependencies, and either run it through the virtualenv or write a wrapper script. Or you find a PyPI package and hope the maintainer keeps it current. Or you try the PyInstaller build from the releases page and discover it was built on Ubuntu 20.04 and your glibc is too new.

Where Python Wins Anyway

If your CLI is a wrapper around Python libraries with no Rust equivalent, the calculus changes. A tool that calls into NumPy, SciPy, or a machine learning framework is not something you rewrite in Rust without serious cost. Python also wins for internal tools where you control the execution environment. If every machine in your organization runs the same Python version through a managed toolchain, the distribution problem mostly disappears. For tools that need to be modified by non-Rust developers, Python’s lower barrier matters too.

The Actual Decision

The smiling.dev article describes a tool the author wanted to distribute publicly without requiring Python installation. That’s the scenario where Rust’s default output mode is definitively better than anything Python offers. Not marginally better, not better-with-caveats: structurally better, because the problem Rust solves (produce a single native binary) is exactly the problem that Python’s distribution tools are trying and failing to solve.

If you’re building a CLI tool for public distribution and you have any tolerance for a steeper initial learning curve, Rust’s distribution story alone justifies the investment. The fact that you also get significantly faster startup, a much smaller artifact, and a type system that catches argument parsing bugs at compile time is what makes the tradeoff easy.

Was this interesting?