StackPractices
intermediate By Mathias Paulenko

CLI Argument Parsing in Python, JS, Java, Go, and Rust

Build command-line tools that handle flags, positional arguments, subcommands, and validation in Python, JS, Java, Go, and Rust.

Topics: devops

Command-line tools still run most developer workflows, DevOps automation, and data-processing pipelines. A good CLI gives you clear subcommands, sensible defaults, useful errors, and help that generates itself.

I’ve built and maintained CLI tools across Python, Go, and Rust for deployment automation, data pipelines, and internal developer tooling. The biggest lesson I learned: reach for a framework early. Hand-rolling argument parsing feels fast on day one, but by the time you need --help text, subcommands, or shell completion, a library like argparse, commander.js, or clap already solves it. For secrets and config, pair your CLI with environment variables and config file parsing — never hardcode credentials in flags.

Below are concrete examples of the same deploy CLI in Python, JavaScript, Java, Go, and Rust, using the libraries teams actually use.

When to Use

  • You’re building internal tools, deployment scripts, or small automation utilities.
  • You need a data-processing or ETL pipeline that operators launch from the terminal.
  • You want to expose app functionality to sysadmins or CI/CD pipelines.
  • Your script grew past a few arguments, so a parser keeps it maintainable.
  • You need feature flags toggled via CLI switches in different environments.

When NOT to Use

  • The script is a one-off with one or two flags; plain shell arguments might suffice.
  • A web UI or dashboard makes more sense for non-technical users who shouldn’t touch a terminal.
  • The tool depends on interactive prompts in non-TTY environments.

Solution

Python (argparse)

import argparse

def main():
    parser = argparse.ArgumentParser(description="Deploy CLI tool")
    parser.add_argument("environment", choices=["dev", "staging", "prod"],
                        help="Target environment")
    parser.add_argument("--version", default="latest",
                        help="App version to deploy")
    parser.add_argument("--dry-run", action="store_true",
                        help="Simulate without changes")
    parser.add_argument("-v", "--verbose", action="store_true",
                        help="Enable verbose output")

    args = parser.parse_args()
    print(f"Deploying {args.version} to {args.environment}")
    if args.dry_run:
        print("(dry run mode)")

if __name__ == "__main__":
    main()

Python (Typer)

import typer

app = typer.Typer()

@app.command()
def deploy(environment: str, version: str = "latest",
           dry_run: bool = False, verbose: bool = False):
    typer.echo(f"Deploying {version} to {environment}")
    if dry_run:
        typer.echo("(dry run mode)")

if __name__ == "__main__":
    app()

JavaScript (commander.js)

const { Command } = require("commander");
const program = new Command();

program.name("deploy-cli").description("CLI for app deployments").version("1.0.0");

program
  .command("deploy <environment>")
  .description("Deploy to an environment")
  .option("-v, --version <ver>", "App version", "latest")
  .option("--dry-run", "Simulate without changes", false)
  .option("--verbose", "Verbose output", false)
  .action((environment, options) => {
    console.log(`Deploying ${options.version} to ${environment}`);
    if (options.dryRun) console.log("(dry run mode)");
  });

program.parse();

JavaScript (yargs)

const yargs = require("yargs/yargs");
const { hideBin } = require("yargs/helpers");

yargs(hideBin(process.argv))
  .command("deploy <env>", "Deploy to environment", (yargs) => {
    return yargs
      .positional("env", { describe: "Target environment",
                           choices: ["dev", "staging", "prod"] })
      .option("version", { alias: "v", default: "latest" })
      .option("dry-run", { type: "boolean", default: false });
  }, (argv) => {
    console.log(`Deploying ${argv.version} to ${argv.env}`);
  })
  .demandCommand(1, "You need at least one command")
  .help()
  .argv;

Java (picocli)

import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
import java.util.concurrent.Callable;

@Command(name = "deploy-cli",
         description = "CLI for app deployments",
         version = "1.0.0",
         mixinStandardHelpOptions = true)
public class DeployCli implements Callable<Integer> {

    @Parameters(index = "0", description = "Target environment")
    private String environment;

    @Option(names = {"-v", "--version"}, defaultValue = "latest",
            description = "App version")
    private String version;

    @Option(names = "--dry-run", description = "Simulate without changes")
    private boolean dryRun;

    @Option(names = {"-V", "--verbose"}, description = "Verbose output")
    private boolean verbose;

    @Override
    public Integer call() {
        System.out.printf("Deploying %s to %s%n", version, environment);
        if (dryRun) System.out.println("(dry run mode)");
        return 0;
    }

    public static void main(String[] args) {
        int exitCode = new CommandLine(new DeployCli()).execute(args);
        System.exit(exitCode);
    }
}

Go (cobra)

package main

import (
    "fmt"
    "os"
    "github.com/spf13/cobra"
)

var (
    version string
    dryRun  bool
    verbose bool
)

func main() {
    rootCmd := &cobra.Command{
        Use:     "deploy-cli",
        Short:   "CLI for app deployments",
        Version: "1.0.0",
    }

    deployCmd := &cobra.Command{
        Use:   "deploy [environment]",
        Short: "Deploy to an environment",
        Args:  cobra.ExactArgs(1),
        Run: func(cmd *cobra.Command, args []string) {
            env := args[0]
            fmt.Printf("Deploying %s to %s\n", version, env)
            if dryRun {
                fmt.Println("(dry run mode)")
            }
        },
    }

    deployCmd.Flags().StringVarP(&version, "version", "v", "latest", "App version")
    deployCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Simulate without changes")
    deployCmd.Flags().BoolVarP(&verbose, "verbose", "V", false, "Verbose output")

    rootCmd.AddCommand(deployCmd)
    if err := rootCmd.Execute(); err != nil {
        os.Exit(1)
    }
}

Rust (clap)

use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = "deploy-cli", version = "1.0.0",
          about = "CLI for app deployments")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Deploy to an environment
    Deploy {
        /// Target environment
        #[arg(value_enum)]
        environment: Environment,

        /// App version
        #[arg(short, long, default_value = "latest")]
        version: String,

        /// Simulate without changes
        #[arg(long)]
        dry_run: bool,
    },
}

#[derive(clap::ValueEnum, Clone)]
enum Environment {
    Dev,
    Staging,
    Prod,
}

fn main() {
    let cli = Cli::parse();
    match cli.command {
        Commands::Deploy { environment, version, dry_run } => {
            println!("Deploying {} to {:?}", version, environment);
            if dry_run {
                println!("(dry run mode)");
            }
        }
    }
}

Explanation

A CLI framework removes the boring work so you can focus on the tool’s logic:

flowchart diagram: argv
  • Parsing splits deploy prod --version 2.1.0 --dry-run into a structured object.
  • Validation catches invalid choices, missing required flags, and type mismatches before your handler ever runs.
  • Help generation builds --help from your definitions.
  • Subcommands organize complex tools (git push, git pull, git log).
  • Exit codes return 0 on success and non-zero on error, so CI/CD and shell scripts can react.

Exit codes reference

Standard exit codes that most CLI frameworks follow:

CodeMeaningWhen to use
0SuccessCommand completed without errors
1General failureApplication logic error, unexpected exception
2MisuseBad arguments, missing required flags, invalid choices
126Command not executablePermission issue
127Command not foundTypo, missing binary
130Interrupted by SIGINTUser pressed Ctrl+C

I once spent two hours debugging a CI pipeline that kept “succeeding” even when the deploy CLI failed — the tool returned exit code 0 on errors. Always return non-zero on failure; your CI/CD system depends on it.

Debugging and testing CLIs

For debugging, add a --verbose flag that prints intermediate state to stderr. Don’t pollute stdout with diagnostics — other tools may pipe your output. For testing:

  1. Separate logic from CLI wiring — keep business logic in pure functions that don’t touch argv or stdout. Test those directly.
  2. Integration tests via subprocess — spawn the compiled binary with subprocess.run() in Python or exec.Command() in Go. Assert on exit code and stdout/stderr.
  3. Snapshot test --help — since help text doubles as user docs, a snapshot test catches accidental changes when you add or remove flags.
  4. --dry-run in CI — wire --dry-run as a CI step. If it exits non-zero, you catch a bad config before it reaches production.

Shell completion

Cobra (Go) and clap (Rust) generate shell completion scripts automatically:

# Cobra: generate bash completion
deploy-cli completion bash > /etc/bash_completion.d/deploy-cli

# clap: generate completion via clap_complete
deploy-cli completions --shell bash

picocli (Java) supports completion via picocli.AutoComplete. Commander.js and yargs have community plugins. Shell completion is a small effort that pays off every time a user types your tool name.

Variants

LanguageLibraryStyleBest for
PythonargparseStdlib, imperativeNo dependencies, simple scripts
PythontyperType hints, modernRapid development, auto docs
JavaScriptcommander.jsFluent chainNode.js CLI tools, middleware
JavaScriptyargsDeclarative, validationComplex CLIs, nested subcommands
JavapicocliAnnotations, GraalVMEnterprise, native images
GocobraStdlib-like, subcommandsGo CLIs, shell completion
RustclapDerive macrosType-safe, fast binaries

Best Practices

  • Provide --help and --version so users don’t need source code to understand usage.
  • Return correct exit codes: 0 success, 1 general error, 2 misuse, 130 for SIGINT.
  • Support - for stdin/stdout so pipes work: cat data.csv | mytool process - > output.json.
  • Validate early and fail fast; print a clear message with the expected and received values.
  • Keep secrets in environment variables, not in --api-key arguments.

Common Mistakes

  • Printing Error: invalid argument with no context. Tell the user what was expected and what they passed.
  • Building a tool with 20 flags instead of a few subcommands.
  • Hardcoding paths and assuming the local development environment.
  • Sending progress and diagnostics to stdout instead of stderr.
  • Allowing invalid values such as --replicas=-5 to reach the application logic.

See Also

Frequently Asked Questions

Should I use a framework or parse arguments manually?

Use a framework. argparse, commander.js, picocli, cobra, and clap handle quotes, escapes, unknown flags, and help formatting. The time you save outweighs the dependency cost.

How do I handle configuration files alongside CLI arguments?

Load a config file as defaults, then let CLI arguments override specific values. The precedence order is: CLI args > env vars > config file > hardcoded defaults.

How do I test a CLI tool?

Keep business logic separate from CLI wiring. Test the core functions directly, then add a few integration tests that run the binary with subprocess. In Java, test the call() method of the picocli class; in Rust, test the Commands match logic directly.

How do I distribute my CLI tool?

Distribution depends on the language. Python ships via pip or pipx from PyPI. JavaScript goes through npm install -g or npx. Go produces a single binary — distribute it with go install or GitHub Releases. Rust uses cargo install from crates.io. Java can compile to a GraalVM native image for fast startup, or ship as a JAR.

Why does my CLI exit with code 2?

Exit code 2 means the user invoked the tool wrong — a bad argument, a missing required flag, or an invalid choice. Most frameworks (argparse, cobra, clap) return 2 automatically when parsing fails. If you're returning 1 for bad arguments, switch to 2 so shell scripts can tell "user error" apart from "application error".

What's the difference between flags and positional arguments?

Positional arguments are identified by their position in the command line (deploy prod), while flags are named (deploy --env prod). Use positional arguments for the primary subject of the command (the environment, the file path) and flags for optional modifiers (version, verbose, dry-run). With more than two positional arguments, consider restructuring into subcommands.

Can I use environment variables alongside CLI flags?

Yes. The standard precedence is CLI flags > env vars > config file > hardcoded defaults. This lets you set sensible defaults in a config file, override per-environment via env vars, and override per-invocation via flags. See the environment variables recipe for patterns on loading and validating env vars.