Architecture

Mutually Exclusive Options in argparse

Enforce option rules in argparse: mutually exclusive groups, required groups, ‘A requires B’ dependencies, defaults that interact, clear errors and tests for each rule.

Updated

Some options cannot be combined. A report can be --json or --csv, not both. A sync can be --dry-run or --force, but combining them is almost certainly a mistake. Other options only make sense together: --cert requires --key, --output-dir is meaningless without --save. Leaving these rules unchecked produces the worst kind of CLI behaviour — silently ignoring one of the flags the user typed. argparse enforces simple exclusivity for you with mutually exclusive groups, including "exactly one of these is required". Anything more nuanced — dependencies between options, rules involving values — needs a small validation step after parsing that reports errors in argparse's own format. This guide covers both, and how to test every rule. It belongs to the command-line parsing with argparse topic.

Prerequisites

Exclusive groups

A mutually exclusive group An argparse mutually exclusive group containing three output options, of which at most one, or with required=True exactly one, may be given. A mutually exclusive group add_mutually_exclusive_group(required=True) argparse --json machine output --csv spreadsheet output --table human output argparse enforces at most one required=True enforces exactly one The usage line shows ( a | b | c ) Groups express "one of these"; anything subtler needs validation after parsing.

parser.add_mutually_exclusive_group() returns a group object with the same add_argument method as the parser. argparse then rejects any command line that uses more than one option from the group. With required=True, it also rejects a command line that uses none of them — "exactly one" semantics. The usage line reflects the rule automatically: [--json | --csv] for an optional group, (--json | --csv | --table) for a required one.

The recipe

# src/mytool/cli.py
from __future__ import annotations

import argparse
from collections.abc import Sequence
from pathlib import Path


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(prog="report", allow_abbrev=False)

    fmt = p.add_mutually_exclusive_group()
    fmt.add_argument("--json", dest="format", action="store_const", const="json")
    fmt.add_argument("--csv", dest="format", action="store_const", const="csv")
    fmt.add_argument("--table", dest="format", action="store_const", const="table")
    p.set_defaults(format="table")

    mode = p.add_mutually_exclusive_group()
    mode.add_argument("--dry-run", "-n", action="store_true", help="Show what would be written.")
    mode.add_argument("--force", "-f", action="store_true", help="Overwrite existing output.")

    p.add_argument("--output-dir", type=Path, help="Write the report here (requires --save).")
    p.add_argument("--save", action="store_true", help="Save the report instead of printing it.")
    p.add_argument("--cert", type=Path, help="Client certificate (requires --key).")
    p.add_argument("--key", type=Path, help="Client key (requires --cert).")
    p.add_argument("--since", help="Start date, YYYY-MM-DD.")
    p.add_argument("--until", help="End date, YYYY-MM-DD; must not be before --since.")
    return p


def validate(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
    """Cross-option rules that exclusive groups cannot express."""
    if args.output_dir is not None and not args.save:
        parser.error("argument --output-dir: only allowed with --save")
    if (args.cert is None) != (args.key is None):
        parser.error("arguments --cert and --key must be given together")
    if args.since and args.until and args.until < args.since:
        parser.error(f"argument --until: {args.until} is before --since {args.since}")


def main(argv: Sequence[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    validate(parser, args)
    print(f"format={args.format} save={args.save} dry_run={args.dry_run}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Design notes

Share one dest for format flags. Pointing --json, --csv and --table at the same destination with store_const gives the program one value to read (args.format) instead of three booleans to reconcile. The group guarantees only one can set it; set_defaults supplies the default. This is often cleaner than a --format {json,csv,table} option when each format is common enough to deserve its own short flag — and you can offer both.

Use exclusive groups for "at most one", and nothing else. --dry-run and --force are a natural group: combining "change nothing" with "overwrite everything" is contradictory, and an error is far better than silently preferring one.

Validate dependencies after parsing, with parser.error(). "--output-dir requires --save" and "--cert and --key go together" cannot be expressed by groups. A validate function run right after parse_args handles them, and parser.error() makes the errors look and exit exactly like argparse's own — usage line, message, status 2.

Validate value relationships there too. "--until must not be before --since" involves values, not presence. ISO dates compare correctly as strings, which keeps the example short; in real code, convert with type=date.fromisoformat first.

Group, or validate after parsing? A decision between argparse mutually exclusive groups and custom validation after parse_args, based on whether the rule is simple exclusivity. Group, or validate after parsing? Is the rule exactly "at most one of these"? Yes — plain exclusivity Exclusive group free usage + errors No — "A requires B", "A only with C" Validate after parser.error(...) Dependencies between options are common in real tools; groups cannot express them.

Conflicts that involve config files and environment variables

Exclusive groups only see the command line. Real tools also read configuration files and environment variables, and conflicts can span sources: a config file sets format = "csv" while the user types --json. That is not a conflict — the command line should simply win, following the usual precedence of flags over environment over files over defaults. The rules in this guide should therefore be checked on the merged settings, after precedence has been applied, not only on the parsed arguments.

The practical shape is: parse arguments, load configuration, merge with flags taking priority, then run validate on the merged result. Error messages should then say where each conflicting value came from — "--cert given on the command line but key is not set in the command line, MYTOOL_KEY or ~/.config/mytool/config.toml" — because a user who never typed an option cannot fix a conflict they do not know about. Keeping track of each value's source during merging makes this straightforward; see config precedence: flags, env, files and defaults.

Groups and subparsers

Exclusive groups belong to one parser, so each subcommand has its own. Rules that apply to several subcommands can live in a parent parser passed with parents=[...] — the group is copied into each subparser. There is one limitation to know: argparse does not support nesting groups (an exclusive group inside an argument group was deprecated in Python 3.11), so if you want grouped help sections and exclusivity, keep the exclusive group at the top level and describe it in the option help. Subparser structure is covered in argparse subparsers for subcommands.

UX considerations

Errors argparse writes for you Terminal output of argparse rejecting two mutually exclusive options and a missing required choice from a required group. Errors argparse writes for you bash $ report --json --csv usage: report [-h] (--json | --csv | --table) report: error: argument --csv: not allowed with argument --json $ report report: error: one of the arguments --json --csv --table is required Both errors exit with status 2 and print the usage line first.
  • Error, never silently prefer. When two options conflict, the user meant something by each; picking one quietly guarantees surprise.
  • Name both sides. "argument --csv: not allowed with argument --json" (argparse's own wording) and "--cert and --key must be given together" tell the user exactly which options are involved.
  • Say it in help too. "(requires --save)" in the help text for --output-dir teaches the rule before anyone breaks it.
  • Prefer a required positional or choice over a required group when natural. report {json,csv,table} may read better than a required group of three flags; required options are often a sign an argument wants to be positional.
  • Keep validation next to parsing. Running validate immediately after parse_args means the rest of the program can assume the rules hold, instead of re-checking them in every function.

Testing the behaviour

Every rule deserves a test for the accepted combination and the rejected one. parse_args raises SystemExit(2) on errors, and capsys captures the message:

# tests/test_rules.py
import pytest

from mytool.cli import build_parser, main, validate


def run(argv):
    parser = build_parser()
    args = parser.parse_args(argv)
    validate(parser, args)
    return args


def assert_usage_error(argv, capsys, fragment):
    with pytest.raises(SystemExit) as info:
        run(argv)
    assert info.value.code == 2
    assert fragment in capsys.readouterr().err


def test_format_defaults_to_table():
    assert run([]).format == "table"


def test_one_format_flag():
    assert run(["--csv"]).format == "csv"


def test_two_format_flags_conflict(capsys):
    assert_usage_error(["--json", "--csv"], capsys, "not allowed with argument --json")


def test_dry_run_and_force_conflict(capsys):
    assert_usage_error(["-n", "-f"], capsys, "not allowed with")


def test_output_dir_requires_save(capsys):
    assert_usage_error(["--output-dir", "out"], capsys, "only allowed with --save")
    assert run(["--output-dir", "out", "--save"]).save


@pytest.mark.parametrize("argv", [["--cert", "c.pem"], ["--key", "k.pem"]])
def test_cert_and_key_together(argv, capsys):
    assert_usage_error(argv, capsys, "must be given together")


def test_until_before_since(capsys):
    assert_usage_error(["--since", "2026-09-10", "--until", "2026-09-01"], capsys, "is before")


def test_main_runs():
    assert main(["--json", "--save"]) == 0

Testing run() rather than only parse_args() ensures the post-parse rules are exercised exactly as the real entry point applies them. Parametrised tests keep symmetric rules — --cert without --key and vice versa — to a single function.

Conclusion

Use argparse's mutually exclusive groups for "at most one of these" and required=True for "exactly one"; they give you correct errors and usage lines for free. For everything groups cannot express — options that require each other, options that only make sense with another, rules about values — add a validate step immediately after parsing that reports problems with parser.error(). Never resolve a conflict by silently preferring one option, mention the rules in help text, and give every rule an accepting and a rejecting test.

Frequently asked questions

Can an exclusive group include a positional argument?

Yes, if the positional is optional (nargs="?"), so that it can be absent when another member is used. For example, a source file or --stdin. Required positionals cannot be in an exclusive group.

How do I make "none of these, or exactly one" the default?

That is an ordinary (non-required) exclusive group: zero or one member may be given. required=True changes it to exactly one.

Can groups express "at least one of these"?

No. Check it after parsing: if not (args.a or args.b): parser.error("one of --a or --b is required"). If exactly one is acceptable, a required exclusive group expresses it more precisely.

Should validation report every problem or stop at the first?

argparse itself stops at the first error, and parser.error() exits immediately, so the simple approach reports one problem per run. For commands with many interacting options, collecting all violations into a list and reporting them together — then calling parser.error() once with the combined message — saves users a frustrating fix-one-rerun loop. Keep the first-error behaviour for small tools; switch when you see people hitting two rules in a row.

Does set_defaults interact badly with exclusive groups?

No — defaults apply only when no member was given. Just avoid giving each member its own non-None default for a shared dest, since the last one defined wins and the result is confusing; use set_defaults once instead.