Engineering Atlas › Low-Level Design

Code a command-line argument parser

16 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

Out of scope: shell completion, config files and environment-variable fallbacks, repeatable options that collect lists, and mutually exclusive groups. Name them, park them in Extensibility, and move on.

Entities and relationships

Start with the nouns that hold declared facts, then the one noun that holds the outcome.

Relationships: a command composes its option specs and positional specs (they exist only for that command) and aggregates child commands. Lookup of an option walks from the current command up through its ancestors, so a child sees its parents' options but not the reverse. A command with children has no positionals of its own, because the first operand would be ambiguous between a subcommand name and a value.

Class diagram

classDiagram
    class Command {
      +name: string
      +help: string
      -options: OptionSpec[]
      -positionals: PositionalSpec[]
      -children: Command[]
      +flag(long, short, help) Command
      +option(long, spec) Command
      +positional(name, required) Command
      +subcommand(cmd) Command
      +parse(argv) Result
      +usage(path) string
      +helpText(path) string
    }
    class OptionSpec {
      +long: string
      +short: string
      +isFlag: bool
      +default: Value
      +required: bool
      +kind: str_or_int
      +choices: Value[]
    }
    class PositionalSpec {
      +name: string
      +required: bool
    }
    class Result {
      +path: string[]
      +values: Map~string, Value~
    }
    class ParseError {
      +message: string
    }
    Command "1" *-- "many" OptionSpec
    Command "1" *-- "many" PositionalSpec
    Command "1" o-- "many" Command : children
    Command ..> Result : returns
    Command ..> ParseError : returns

Key flows

parse is one left-to-right pass that classifies each token using only the current command and its ancestors, then a validation pass over the finished tree path. Two phases keep the logic simple: the scan decides what each token is, and the finish step applies defaults, required checks and positional assignment.

sequenceDiagram
    participant M as main
    participant R as root Command
    participant C as child Command
    M->>R: parse(["build", "--target", "prod", "-v", "core"])
    R->>R: token "build": root has children, find child
    R->>C: descend to build
    C->>C: "--target": look up in build, then root
    C->>C: take next token "prod" as the value, convert, check choices
    C->>C: "-v": not on build, found on root, set true
    C->>C: "core": operand, queue it
    C->>C: end of input: defaults, required options, assign operands
    C-->>M: Result(path=[build], values)
    Note over M: on any failure, a ParseError comes back instead

The classification rules, in the order the code applies them:

  1. After --, every token is an operand.
  2. --name or --name=value is a long option. = splits at the first sign only, so a value may itself contain =.
  3. -abc is a short cluster. Walk the letters: a flag sets itself and the walk continues; an option takes the rest of the cluster as its value, or the next token if nothing remains, and the walk stops. This is the grouping rule POSIX describes: any number of flags, then at most one option that takes a value (POSIX Utility Syntax Guidelines).
  4. A lone - is an operand (by convention it means standard input).
  5. Anything else is an operand, or the next subcommand name if the current command has children.

An option consumes the next token as its value even when that token looks like a flag. That is what makes --offset -5 work. The cost is that --out --verbose silently stores the string --verbose. Real parsers choose differently; the next sections come back to it.

Design patterns used and why

Concurrency and edge cases

Parsing is a pure function of the declaration and the input, so concurrency is easy: once a command tree is built and not mutated, any number of goroutines or threads can call parse on it. Never store parse state (the cursor, the partial result) on the Command. That is the one real mistake, and it turns a read-only tree into shared mutable state.

In the wild

Down the stack

Layer 1The contract

parse(argv) returns either a result in which every declared option and positional has a value (the supplied one, the default, or null for an optional operand nobody gave) or a single error and nothing else. It never returns a partial result. It is order-insensitive for options relative to each other, as POSIX recommends, and order-sensitive for operands. Errors are specific enough to test: unknown option, missing value, bad integer, value outside the choices, missing required option, missing operand, extra operand, missing or unknown subcommand. Given the same declaration and input, the output is identical on every run.

Layer 2The mechanism

The data structures are small. Each command keeps its option specs in a list (a map from long name and from short letter is the next step for commands with dozens of options) and a list of positional specs. Option lookup walks from the current command up the ancestor chain, so a child's declaration shadows its parent's. The scan is a single index into argv; consuming a value just advances that index. The finish step iterates the whole chain to apply defaults, which keeps "unset" separate from "set to the default" until the end and lets a required check mean something.

The suggestion uses Levenshtein edit distance, a dynamic programme over two rows of integers. The rule here is distance under 3 and under the length of the word, picking the alphabetically first among ties so the message is stable. Argparse implements long-option abbreviation by prefix search over its option map and reports "ambiguous option" for more than one hit; from 3.14 it can also suggest close names for mistyped choices and subcommands (argparse docs).

Layer 3The machine

The kernel never sees flags. A shell expands quotes, variables and globs, then calls execve with an array of C strings; each string is NUL-terminated and the whole block sits at the top of the new process's stack. Linux caps a single string at 32 pages, 128 KiB, and the total size of arguments plus environment at a limit derived from the stack resource limit, so a huge glob can fail with E2BIG before your parser runs (execve(2)). Your program receives argv as a pointer array; Go copies it into os.Args and Python into sys.argv, one allocation per string.

Parsing cost is negligible: a few hundred nanoseconds for a typical ten-token line, against roughly a millisecond or more to fork and exec the process in the first place. What deserves attention is what the arguments are on the machine: readable by other users through /proc/<pid>/cmdline and ps for the life of the process, which is why a secret in argv is a leak.

Why it's built this way

  1. The kernel passes a flat string array and nothing else, so all structure (flag, value, operand) must be recovered in user space. Every convention exists to make that recovery unambiguous.
  2. Humans type the line and scripts generate it, so the grammar has to be both short to type (-vq) and safe to generate (--name=value, --).
  3. Help drifts from code when written separately, so the declaration is a single source of truth that generates both parser behaviour and help text.
  4. Programs nest (git, kubectl), so the declaration is a tree, and a recursive type is the cheapest way to handle any depth.
  5. Parsing is called once per process but tested thousands of times, so it is pure, returns errors as values and leaves exit codes and printing to main.

Extensibility

Leadership lens

Check yourself

  1. Why should parse return an error instead of printing a message and exiting?

    Show answer

    A parser that exits cannot be embedded in a larger program, called from tests, or reused to validate input from a source other than a terminal. Returning the error lets only main choose the stream, text decoration and exit status.

  2. A user runs app --count -5. Should -5 be a value or an unknown option, and what do you give up either way?

    Show answer

    The usual rule is that an option consumes the next token, so -5 is the value; that supports negative numbers and file names starting with a dash but means --out --verbose stores the string --verbose. Rejecting dash-leading values catches that typo but breaks legitimate input, unless you add a negative-number special case as argparse does.

  3. Why are option abbreviations risky for scripts?

    Show answer

    A prefix that is unique today can become ambiguous when a later release adds an option with the same prefix, turning a working script into an error. Scripts should spell options in full, which is why Git's documentation says so.

  4. Where in the class design does the Composite pattern pay off, and where would it be overkill?

    Show answer

    It pays off in parse, usage and helpText, which recurse over a command tree of any depth with one type. It is overkill for a single flat command with no subcommands, where a plain spec list is enough.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →