Typos are the most common error a CLI sees: stauts for status, --forse for --force, --env stagign, a service called api-gatway. The difference between a frustrating tool and a friendly one is often a single sentence at the end of the error — Did you mean 'staging'? — which turns a dead end into a one-key fix. Modern frameworks already provide this for commands and options; the gaps are option values, names of things your tool manages (services, projects, profiles), and commands you renamed between releases. This guide shows what you get for free, then fills those gaps with a small helper built on the standard library's difflib, a choice type that suggests, and aliases that keep old command names working. It belongs to the error handling topic.
Prerequisites
- Click 8.x or Typer (examples checked with Click 8.5 and Typer 0.27); argparse on Python 3.14 for its equivalent.
- The error conventions from friendly error messages and tracebacks.
What you already get
Current Click suggests close matches for unknown commands and options:
Error: No such command 'stauts'. (Did you mean one of: 'start', 'status'?)
Error: No such option '--forse'. (Did you mean one of: '--force', '--format'?)
Typer renders the same idea in its error panel ("No such command 'stauts'. Did you mean 'status', 'start'?"), and Python 3.14's argparse adds suggestions for invalid choices when suggest_on_error is enabled, as shown in argument groups and help formatting in argparse. What none of them can do is suggest data: a mistyped service name only your API knows about, or a choice value in Click, where --env stagign produces "is not one of 'dev', 'staging', 'production'" without pointing at the obvious fix.
The recipe
A small helper combines two kinds of match: candidates that start with what the user typed (catching abbreviations like prod), and close matches by similarity (catching transpositions like stagign):
# src/mytool/suggest.py
from __future__ import annotations
import difflib
def suggest(value: str, candidates: list[str], *, n: int = 3, cutoff: float = 0.6) -> list[str]:
prefixed = [c for c in candidates if c.startswith(value) and c != value]
close = difflib.get_close_matches(value, candidates, n=n, cutoff=cutoff)
return list(dict.fromkeys(prefixed + close))[:n] # ordered, de-duplicated
def did_you_mean(value: str, candidates: list[str]) -> str:
matches = suggest(value, candidates)
return f" Did you mean {', '.join(repr(m) for m in matches)}?" if matches else ""
difflib.get_close_matches scores candidates by a similarity ratio and returns the best above cutoff. Its default of 0.6 is a good balance: stagign scores well against staging, unrelated words do not. The prefix pass covers what similarity alone misses — prod versus production scores below the cutoff, but it is obviously an abbreviation.
Suggestions for resource names
The most valuable place for suggestions is where the framework cannot help: names of things your tool manages.
# src/mytool/cli.py
import click
from mytool.suggest import did_you_mean
SERVICES = ["api-gateway", "billing", "search-indexer", "web"]
@click.group()
def cli() -> None:
"""Ops tool."""
@cli.command()
@click.argument("service")
def restart(service: str) -> None:
"""Restart SERVICE."""
if service not in SERVICES:
raise click.ClickException(f"unknown service {service!r}." + did_you_mean(service, SERVICES))
click.echo(f"restarting {service}")
$ mytool restart api-gatway
Error: unknown service 'api-gatway'. Did you mean 'api-gateway'?
In a real tool, SERVICES comes from the API — and the list is often already fetched to perform the lookup, so the suggestion costs nothing extra. Where it would require an extra slow call, use cached names, as in caching HTTP responses on disk in a CLI.
A choice type that suggests
For Click choices, subclass click.Choice and add the suggestion to the failure message:
# src/mytool/types.py
import click
from mytool.suggest import did_you_mean
class SuggestingChoice(click.Choice):
def convert(self, value, param, ctx):
try:
return super().convert(value, param, ctx)
except click.BadParameter:
choices = [str(c) for c in self.choices]
self.fail(f"{value!r} is not one of {', '.join(map(repr, choices))}."
+ did_you_mean(str(value), choices), param, ctx)
Error: Invalid value for '--env': 'stagign' is not one of 'dev', 'staging', 'production'. Did you mean 'staging'?
Use it exactly like click.Choice: @click.option("--env", type=SuggestingChoice(["dev", "staging", "production"])). Writing custom Click parameter types covers the type API in general.
Suggestions for missing files
A missing file is a typo more often than a missing file. Listing the parent directory and suggesting close names turns No such file into a fix:
from pathlib import Path
from mytool.suggest import did_you_mean
def missing_file_message(path: Path) -> str:
parent = path.parent if path.parent.is_dir() else Path(".")
siblings = [p.name for p in parent.iterdir()] if parent.is_dir() else []
return f"{path} does not exist." + did_you_mean(path.name, siblings)
d/confg.toml does not exist. Did you mean 'config.toml', 'conf.d'?
Only do this for directories of reasonable size — a directory with a hundred thousand entries makes the error slow — and never for paths outside what the user asked about.
Aliases for renamed commands
When you rename a command, old muscle memory and old scripts keep using the previous name. A suggestion helps people; an alias helps scripts too. A Click group can map old names to new ones with a deprecation warning:
class AliasedGroup(click.Group):
"""Accept renamed commands with a warning."""
RENAMED = {"ls": "list", "rm": "remove", "info": "status"}
def get_command(self, ctx, cmd_name):
cmd = super().get_command(ctx, cmd_name)
if cmd is None and cmd_name in self.RENAMED:
new = self.RENAMED[cmd_name]
click.echo(f"warning: '{cmd_name}' is now '{new}'", err=True)
return super().get_command(ctx, new)
return cmd
def resolve_command(self, ctx, args):
name, cmd, rest = super().resolve_command(ctx, args)
return (cmd.name if cmd else name), cmd, rest
Pass cls=AliasedGroup to @click.group(). The warning goes to stderr so scripts keep working while their authors are nudged towards the new name. Remove aliases in a later major release, following the policy in versioning and deprecating CLI flags.
UX considerations
- Suggest, never auto-correct. Running
restart api-gatewaywhen the user typedapi-gatwayis fine; runningdelete prod-dbwhen they typeddelete prod-dvis not. Suggestions keep the human in the loop. - Keep suggestions short. One to three candidates; a list of ten is just a less readable help screen.
- Put the suggestion at the end of the error, where the eye lands, and keep the exit code unchanged (2 for usage errors, your own code for lookup failures).
- Avoid unique-prefix execution for anything destructive. Some tools run a command when a prefix is unambiguous (
mytool sta→status); that silently changes meaning when a new command with the same prefix is added. - Suggest in JSON mode too, as a field such as
"suggestions": ["staging"]in the machine-readable error from reporting machine-readable errors in JSON mode.
Testing the behaviour
Test the helper with a table of real typos, and the commands for the messages users see:
# tests/test_suggest.py
import click
import pytest
from click.testing import CliRunner
from mytool.cli import cli
from mytool.suggest import suggest
from mytool.types import SuggestingChoice
@pytest.mark.parametrize("typed,expected", [
("stagign", ["staging"]), ("prod", ["production"]), ("dve", ["dev"]), ("xyz", []),
])
def test_suggest(typed, expected):
assert suggest(typed, ["dev", "staging", "production"]) == expected
def test_unknown_service_suggests():
result = CliRunner().invoke(cli, ["restart", "api-gatway"])
assert result.exit_code == 1
assert "Did you mean 'api-gateway'?" in result.output
def test_choice_suggests():
@click.command()
@click.option("--env", type=SuggestingChoice(["dev", "staging", "production"]))
def deploy(env):
click.echo(env)
result = CliRunner().invoke(deploy, ["--env", "stagign"])
assert result.exit_code == 2 and "Did you mean 'staging'?" in result.output
Collect real typos from support requests and add them to the table — they are the best test data you will get.
Conclusion
Most CLIs already suggest corrections for mistyped commands and options; the remaining gaps are values and names. A ten-line helper combining prefix matches with difflib.get_close_matches fills them: append its suggestions to "unknown resource" errors and to a click.Choice subclass, keep renamed commands working through aliases with a stderr warning, never auto-correct destructive actions, and test with the typos your users actually make.
Frequently asked questions
Is difflib good enough, or do I need a fuzzy-matching library?
For candidate lists of tens or hundreds of names, difflib is fast and good enough. Libraries such as rapidfuzz are faster and offer more scoring options, which matters for tens of thousands of candidates or real-time completion.
Should suggestions be case-insensitive?
If your names are case-insensitive, compare lower-cased forms and suggest the canonical spelling. If case matters, suggesting Billing for billing is itself a helpful hint.
How does this relate to shell completion?
They complement each other: completion prevents typos for users who press Tab; suggestions rescue everyone else, including scripts copied from old documentation. Completion is covered in shell completion for Python CLIs.
Can Typer apps use the same suggestions?
Yes — raise typer.BadParameter or typer.Exit with a message built by did_you_mean, or validate values in a parser= function as in validating dates and durations in CLI arguments.
What cutoff should I use?
Start with difflib's default of 0.6 and adjust with real data. Short names need care: two three-letter names differing by one letter score 0.67 and will be suggested for each other, which is usually what you want; very different short names score low and are correctly left out.