1.`BaseCommand`, and the two methods that make one
A 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.
Key features
- The path is `<app>/management/commands/<name>.py`, and both `management/` and `commands/` need to exist
- The class must be named `Command`; the file name is the command name
- `add_arguments(self, parser)` receives an `argparse` parser — the full argparse API is available
- Positional: `parser.add_argument("poll_ids", nargs="+", type=int)`
- Flag: `parser.add_argument("--delete", action="store_true", help="…")`
- `--dry-run` arrives in `handle` as `options["dry_run"]` — argparse converts dashes to underscores
The argument shapes worth knowing, and what each produces
Class attributes that change how the command is run
- 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.
Command
# <app>/management/commands/backfill_currency.py
class Command(BaseCommand):
def add_arguments(self, parser): ...
def handle(self, *args, **options): ...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.