Input & UX

Versioning and Deprecating CLI Flags

Rename or retire a CLI flag without breaking scripts - hidden aliases, stderr warnings, a removal timeline, and the changelog entries that go with it.

Updated

A flag name is a public API. Somewhere there is a Makefile, a cron entry or a CI step that types it, and the person who wrote it is not reading your release notes. Retiring a flag safely is a sequence rather than an edit: alias, warn, hide, remove.

TL;DR

  • Renaming a flag, changing its default, or changing what it means are all breaking changes.
  • Keep the old name as a hidden alias, warn on stderr, and name both the replacement and the removal version.
  • Leave at least two minor releases between the warning and the removal.
  • Changing a flag's meaning while keeping its name is the one users cannot detect — never do it.
  • Record every step in the changelog; that is where a script owner will look.

Prerequisites

A released CLI with users, a version scheme (see managing CLI versioning), and a changelog people can find.

What counts as breaking

Which flag changes are breaking A table classifying command line interface changes as breaking or safe, covering renames, new options, changed defaults and altered meanings. Which flag changes are breaking Change Breaking? Why Rename a flag yes existing scripts stop parsing Change a default yes behaviour changes with no edit Change what a flag means yes — worst kind nothing looks wrong Add an optional flag no nothing existing changes Add an alias no both spellings work Changing a flag's meaning while keeping its name is the change users cannot detect until it hurts.

The test is whether an invocation that worked yesterday behaves differently today. By that measure some changes people treat as minor are not:

Changing a default. --retries moving from 3 to 5 changes behaviour for every user who never passed the flag, without a single edit on their side. If the new default is genuinely better, ship it in a major release and say so prominently.

Tightening validation. Rejecting input you previously accepted fixes a bug and breaks a pipeline that relied on the leniency. It is still the right change; it is a major one.

Changing what a flag means. --timeout moving from milliseconds to seconds while keeping its name is the worst case, because nothing looks wrong and the failure is silent. Add --timeout-seconds instead and deprecate the old name — a rename users can see beats a redefinition they cannot.

The deprecation sequence

Retiring a flag without breaking scripts A deprecation timeline: announce the replacement, accept both names with a warning, then remove the old one at a major version. Retiring a flag without breaking scripts Add the new flag both accepted, no warning 1.5 Warn old flag warns on stderr 1.6 Hide it still works, absent from help 1.7 Remove documented in the changelog 2.0 at least two minor releases between the warning and the removal The warning is what gives a script owner a chance to notice before the removal breaks them.

Four steps, spread across releases. Each is small; the spacing is what makes it safe.

1. Add the replacement, accept both. No warning yet. Users who read release notes migrate early; nobody is nagged.

2. Warn. The old name still works and produces one line on stderr naming the replacement and the removal version.

3. Hide it. Remove it from --help so new users never learn it, while existing scripts keep working.

4. Remove it. At a major version, with a changelog entry, and ideally with an error message that still recognises the old name and says what replaced it.

That last touch is worth the five lines. A user whose script breaks gets "unknown option --retry-count; it was renamed to --retries in 2.0" instead of a generic usage error.

Implementing the alias

How a deprecated flag still works A deprecated flag is accepted as a hidden alias, produces a warning on standard error, and its value is mapped onto the replacement before the command runs. How a deprecated flag still works --old-name given hidden, still parsed Warn on stderr names the replacement Mapped to --new-name one value reaches the command accepted then The command body never learns the old name existed, which keeps the deprecation in one place.

In Click, a parameter can carry several names, and the extra one can be hidden:

@click.option("--retries", "retries", type=int, default=3, show_default=True,
              help="Attempts per file before giving up.")
@click.option("--retry-count", "retry_count", type=int, default=None, hidden=True)
def sync(retries: int, retry_count: int | None) -> None:
    if retry_count is not None:
        click.secho(
            "--retry-count is deprecated and will be removed in 2.0; use --retries",
            fg="yellow", err=True,
        )
        retries = retry_count
    ...

In Typer, a parameter callback keeps the deprecation out of the command body:

def deprecated_retry_count(value: int | None) -> int | None:
    if value is not None:
        typer.secho(
            "--retry-count is deprecated and will be removed in 2.0; use --retries",
            fg=typer.colors.YELLOW, err=True,
        )
    return value

@app.command()
def sync(
    retries: Annotated[int, typer.Option(help="Attempts per file.")] = 3,
    retry_count: Annotated[int | None, typer.Option(hidden=True, callback=deprecated_retry_count)] = None,
) -> None:
    retries = retry_count if retry_count is not None else retries
    ...

Three properties matter in both versions. The warning goes to stderr, so it cannot contaminate piped output. It fires once, not per use. And the new name wins if somebody passes both, so a half-migrated script behaves predictably.

For more than one or two deprecations, a small helper keeps the pattern in one place:

DEPRECATED = {"--retry-count": ("--retries", "2.0"), "--dir": ("--source", "2.0")}

def warn_deprecated(flag: str) -> None:
    replacement, removal = DEPRECATED[flag]
    typer.secho(
        f"{flag} is deprecated and will be removed in {removal}; use {replacement}",
        fg=typer.colors.YELLOW, err=True,
    )

That dictionary then feeds the warning, the changelog generation and the test suite, which is what keeps the three consistent.

Deprecating a whole command

The same sequence works, with one addition: a deprecated command should say so in its own help text as well as at run time.

@app.command(help="[DEPRECATED] Use 'mytool sync' instead. Removed in 2.0.", hidden=True)
def upload(source: Path) -> None:
    typer.secho("'upload' is deprecated; use 'sync' (removed in 2.0)", fg=typer.colors.YELLOW, err=True)
    sync(source)

Delegating to the replacement rather than duplicating the implementation means the deprecated path cannot rot separately — and when the removal comes, deleting the function is the entire change.

UX considerations

Warn once per run, not per file. A loop that warns on every iteration produces thousands of lines and trains people to redirect stderr to /dev/null, which is exactly the stream your errors use.

Give a suppression escape hatch. MYTOOL_NO_DEPRECATION_WARNINGS=1 is one line and it matters for teams who have seen the warning, planned the migration, and need a clean CI log until then.

Say the version. "Deprecated" is ignorable; "removed in 2.0" is a deadline someone can put in a ticket.

Do not warn for something the user cannot fix. If a deprecated flag is being passed by a tool you also ship, fix that tool first — a warning nobody can act on is noise.

Testing the behaviour

Deprecation logic is easy to break by accident and cheap to pin:

def test_old_flag_still_works_and_warns(cli):
    result = cli.invoke(app, ["sync", "data", "--retry-count", "7"])

    assert result.exit_code == 0
    assert "deprecated" in result.stderr
    assert "--retries" in result.stderr          # the message names the replacement
    assert "2.0" in result.stderr                # and the removal version

def test_new_flag_wins_when_both_are_given(cli, monkeypatch):
    seen = {}
    monkeypatch.setattr("mytool.commands.sync.core.sync_directory",
                        lambda source, **kw: seen.update(kw) or SyncResult(0, 0))

    cli.invoke(app, ["sync", "data", "--retries", "3", "--retry-count", "9"])

    assert seen["retries"] == 3

def test_deprecated_flag_is_hidden_from_help(cli):
    assert "--retry-count" not in cli.invoke(app, ["sync", "--help"]).stdout

The middle test is the one that catches real mistakes — precedence between an old and new flag is easy to get backwards, and the symptom is a script that silently keeps its old behaviour after migrating.

Conclusion

Deprecation is a schedule, not a decision. Add the replacement, accept both, warn with the replacement name and the removal version, hide the old name, then remove it at a major release. Every step is a few lines; the spacing between them is what turns a breaking change into one nobody notices until they have already migrated.

Frequently asked questions

How long should a deprecation period be?

At least two minor releases between the first warning and the removal, and longer for a tool used in automation, where nobody is watching output day to day. Time matters less than release count: someone pinned to 1.4 experiences your deprecation only when they upgrade.

Should a deprecated flag warn on stdout or stderr?

Stderr, always. A warning on stdout corrupts piped output, which turns a courtesy into a breaking change of its own. This is the same rule that governs every non-result message your tool produces.

What if I need to change a default rather than a name?

Announce it a release ahead, warn when the user is relying on the old default (they passed nothing and the value is about to change), and make the change at a major version. If the difference is dangerous — a default that starts deleting things — consider a new flag instead and leave the default alone.

How do I deprecate a positional argument?

The same way, with one difference: positional arguments cannot be aliased, so accept the old arity and warn. For example, if mytool sync SOURCE DEST becomes mytool sync SOURCE --dest DEST, accept the trailing positional as optional, warn when it is used, and remove it at the major version.

Do I need a deprecation policy in the documentation?

A short one helps: "flags are deprecated for at least two minor releases before removal; removals happen only in major versions". It sets an expectation, and it saves the argument about whether a particular removal was too fast.

Can I detect who is still using a deprecated flag?

Not remotely, and you should not try — a CLI phoning home about usage is a much bigger imposition than the deprecation. What you can do is make the warning specific enough that whoever sees it can find the script, which means naming the flag, the replacement and the removal version in one line.

What belongs in the changelog for a deprecation?

Three entries across three releases: the release that adds the replacement ("added --retries, which replaces --retry-count"), the release that starts warning, and the major release that removes it. Each names both flags. A reader upgrading across several versions can then reconstruct what happened without reading the source.