`BaseCommand`, and the two methods that make one
coreintermediateA management command is a class called `Command` in `<app>/management/commands/<name>.py`, subclassing `BaseCommand`. It needs at most two methods: `add_arguments(self, parser)` declares what the command accepts, and `handle(self, *args, **options)` does the work. The parser is a standard `argparse` parser, so `parser.add_argument("order_ids", nargs="+", type=int)` gives you positional arguments and `parser.add_argument("--dry-run", action="store_true")` gives you a flag. The file name is the command name — nothing is registered anywhere.
Think of it as
The value of a management command is not that it saves you writing a script. It is that it runs *inside* your project: settings loaded, apps populated, database connections configured, the ORM usable. A loose script has to reproduce all of that and will drift from it; a command cannot drift, because it is the same code your site runs. That is why the roadmap's list of uses — backfills, reconciliation, cleanup, imports, exports, maintenance — belongs here rather than in a `scripts/` directory. Think of the structure as a contract in two halves. `add_arguments` is the *interface*: everything the command accepts, with its type and its help text, declared in one place that `--help` reads. `handle` is the *implementation*, and it receives the parsed values as keyword arguments — a dashed flag arrives with underscores, so `--dry-run` is `options["dry_run"]`. Keeping the halves separate matters more than it looks, because argparse is doing real work for you: types are converted and validated before `handle` runs, so a non-integer id is rejected with a usage message rather than crashing halfway through a loop that has already written to the database. The design instinct worth forming early is to make the interface explicit rather than clever. Prefer a required positional over a magic default, because a backfill that silently defaults to "everything" is the command people run once by accident. Prefer an opt-in `--apply` over an opt-out `--no-dry-run`, so the dangerous direction is the one you have to type. And give every argument `help=` text: the person reading `--help` at 3 a.m. during an incident is usually not the person who wrote it.
What we're doing: A backfill command whose interface is fully declared — explicit targets, an opt-in write flag, a tunable batch size — so `--help` describes it completely.
- 7
- `help` is not decoration. It is the first thing anyone sees, and a command whose purpose is only discoverable by reading `handle` gets run wrongly.
- 10
- `requires_migrations_checks = True` warns when migrations on disk do not match the database. For a backfill that assumes a column exists, that warning is the difference between a clear message and a confusing `ProgrammingError`.
- 13–18
- `nargs="*"` accepts zero or more, which makes "everything" expressible — but the `--apply` flag below is what keeps that safe, because the default run writes nothing.
- 19–23
- Opt-in rather than opt-out. `--apply` means the dangerous direction is the one you have to type; a `--no-dry-run` flag inverts that and makes the default destructive.
- 30
- Filtering to the rows that actually need work — rather than every row — is what lets the command be re-run safely, which the idempotency concept builds on.
- 36–37
- `CommandError` is the right way to reject bad input inside `handle`: Django catches it, prints it to stderr, and exits non-zero, with no traceback for what is a usage problem.
Why this works: `--help` describes the whole interface, argparse rejects malformed input before any row is touched, and running the command with no flags reports instead of writing.
Reading `sys.argv` instead of declaring arguments
Wrong
Better
What you see: `--help` shows a command that appears to take no arguments, and a typo in a flag name is silently ignored — so a run everyone believed was a dry run was not.
Why: argparse is not a formality; it is the validation layer. Declaring an argument gets you type conversion, a `--help` entry, and rejection of unknown flags before `handle` runs. Reading `sys.argv` bypasses all three: a misspelled `--dry-runn` is not an error, it is simply absent, and the command proceeds in whatever mode the absence implies. For a destructive command, that turns a typo into a data change.
- Whole: parser.add_argument("--batch-size", type=int, default=500, help="Rows per transaction")
- "--batch-size" — Optional flag: The leading dashes are what make it optional. It reaches `handle` as `options["batch_size"]` — argparse converts the dash to an underscore.
- type=int — Converted and validated: A non-integer is rejected with a usage message *before* `handle` runs, so the command cannot fail halfway through having already written rows.
- default=500 — Always present: With a default, the key always exists, so `handle` needs no `.get()` and no `or 500` fallback.
- help="Rows per transaction" — Read at 3 a.m.: This is what `--help` prints. The person running the command during an incident is usually not the person who wrote it.
The argument shapes worth knowing, and what each produces
Together
Class attributes that change how the command is run
Together
Remember: A command is a `Command` class in `<app>/management/commands/<name>.py` — both directory levels required, the app in `INSTALLED_APPS`, the file name is the command name. `add_arguments` declares the interface, `handle` implements it, and argparse validates types before `handle` runs. Dashes become underscores: `--dry-run` is `options["dry_run"]`. Make the destructive direction opt-in (`--apply`, not `--no-dry-run`), give every argument `help=` text, and raise `CommandError` for bad input rather than letting a traceback stand in for a usage message.
See also: output errors and exit codes · dry run batching and idempotency · what commands are for

