Filter concepts by levelShowing all levels.

Django · Management Commands

Management Commands

Two methods, one exit code, and a command you can safely run twice

  • <app>/management/commands/<name>.pyboth directory levels, class named Command, app in INSTALLED_APPS
  • The exit code is the contractprinting "failed" and returning exits 0 — into a green build
  • Select by outcome, not by offsetthe work set shrinks, so a re-run resumes with no bookmark
  • Batch size is the interruption costone atomic() per batch, never one around the whole run
  • Dry run by default, --apply to writeand run the real path, so the dry run cannot drift from it

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

The argument shapes worth knowing, and what each produces
Declaration`options[...]` / `args`Use for
`add_argument("order_id", type=int)`exactly one int, requiredthe single-target case
`add_argument("ids", nargs="+", type=int)`a list, at least onebatch of explicit targets
`add_argument("--since", type=parse_date)``None` unless passeda window; argparse validates the type
`add_argument("--apply", action="store_true")``False` unless passedopt in to the dangerous direction
`add_argument("--batch-size", type=int, default=500)`an int, always presenta tunable with a sane default
`add_argument("--mode", choices=["a", "b"])`one of the choicesclosing a set — argparse rejects the rest

Class attributes that change how the command is run

Class attributes that change how the command is run
AttributeDefaultWhat it does
`help``""`the description `--help` prints
`requires_migrations_checks``False`"prints a warning if the set of migrations on disk don't match" the database
`requires_system_checks``'__all__'`runs system checks before executing; a list of tags narrows it
`output_transaction``False`wraps printed SQL in `BEGIN;` / `COMMIT;`
One declaration line, and everything argparse does with it before `handle` runs

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.

  • 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

python
# <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.

2.Output, errors and the exit code something else is reading

Write with `self.stdout.write(...)` and `self.stderr.write(...)`, not `print`. The documented reason is testability — the proxies can be captured — but there is a second: `print` always goes to real stdout, so it cannot be redirected by a caller. Colour comes from `self.style.SUCCESS(...)` / `ERROR` / `WARNING`, and it degrades cleanly because `--no-color` makes every `self.style()` call return the original string. For failure, raise `CommandError`: Django prints it to stderr without a traceback and exits non-zero.

Key features

  • Use `self.stdout` / `self.stderr` — "it becomes much easier to test your custom command"
  • `self.style.SUCCESS` / `ERROR` / `WARNING`; `--no-color` makes every `self.style()` call return the original string
  • `raise CommandError(...)` → message on stderr, no traceback, non-zero exit
  • `CommandError` takes an optional `returncode`; it defaults to `1`
  • `self.stdout.write("…", ending="")` suppresses the newline, for in-place progress
  • `handle()` may return a string, and Django prints it to stdout

What goes where, and who is reading it

What goes where, and who is reading it
Stream / valueAudiencePut here
`self.stdout`a pipe, a file, a personthe *result* — rows, ids, the CSV
`self.stderr`a person, a logprogress, warnings, diagnostics
exit code `0`cron, CI, `&&`the work completed
exit code `1`cron, CI, alerting`CommandError` — the default failure
a custom `returncode`a wrapper scriptdistinguishing "nothing to do" from "broke"
a structured log linetomorrow morninganything you will want after the terminal closes
The same failure, and what the cron job that wraps it sees

Caught and printed

  • The message is on the screen, and nowhere else
  • The process exits 0 — success, as far as anything else knows
  • cron sends no mail; the CI step is green
  • The alert that exists for this never fires
  • Discovered days later, by missing data

Raised as CommandError

  • The message goes to stderr, where diagnostics belong
  • The process exits 1 — and non-zero is the only signal a wrapper has
  • cron mails the output; the CI step goes red
  • No traceback: this is an expected failure, reported cleanly
  • Discovered in minutes, by the alert that already existed
  • Caught and printed
    • The message is on the screen, and nowhere else
    • The process exits 0 — success, as far as anything else knows
    • cron sends no mail; the CI step is green
    • The alert that exists for this never fires
    • Discovered days later, by missing data
  • Raised as CommandError
    • The message goes to stderr, where diagnostics belong
    • The process exits 1 — and non-zero is the only signal a wrapper has
    • cron mails the output; the CI step goes red
    • No traceback: this is an expected failure, reported cleanly
    • Discovered in minutes, by the alert that already existed

Command

python
raise CommandError("nothing to reconcile", returncode=2)

Write to `self.stdout` / `self.stderr`, never `print` — the proxies are capturable and redirectable. Results on stdout, progress and diagnostics on stderr, so a redirect still produces a clean file. Colour with `self.style.SUCCESS`/`ERROR`/`WARNING`, which degrades to plain text under `--no-color`. Above all: the exit code is the only thing a cron job or CI step can see, so raise `CommandError` (exit 1 by default, or a `returncode` you choose) instead of catching an exception, printing "failed", and exiting zero into a green build.

3.Dry-run, batching, and being safe to run twice

A command that touches production data needs three properties. It must be able to show what it *would* do without doing it — a dry run, and it should be the default. It must work in **batches** inside their own transactions, so a failure at row 300,000 does not roll back the previous 299,999 or hold one enormous lock. And it must be **idempotent**: running it twice produces the same result as running it once, which is what makes it safe to re-run after the failure that will eventually happen.

Key features

  • Select by outcome (`filter(currency="")`), not by offset — the work set shrinks, so a re-run resumes for free
  • One `atomic()` per batch: all-or-nothing at a size you choose, instead of one enormous lock
  • `.iterator(chunk_size=…)` streams rows and does not populate the result cache — a plain loop loads every row
  • Batch size is the interruption cost: it is how much work a crash throws away
  • Dry run should be the default; make writing the flag you have to type
  • A dry run computed by a second code path is a dry run that can lie

Transaction scope — the trade you are actually making

Transaction scope — the trade you are actually making
ScopeYou getYou pay
one transaction, whole runtrue all-or-nothinga long write lock, huge undo log, a rollback that takes as long as the run
one per batcha bounded unit of loss and of lockingpartial completion is possible — which idempotency makes safe
one per rowno lock contentionno grouping at all, and one round trip per row
none (autocommit)nothing to think abouta half-applied multi-write change on any failure

Two honest ways to implement a dry run

Two honest ways to implement a dry run
ApproachHowWatch for
gate the writes`if apply: obj.save()`every write path must check the flag — one that forgets is a real write
roll back deliberately`atomic()` + `set_rollback(True)`side effects outside the database still happen — emails, HTTP calls, files
(anti-pattern) report separatelya second code path that printsthe two paths drift, so the run you tested is not the run that happens
A command that is killed at row 300,000 — and what happens when you run it again
take 500successnext batchinterruptedautomaticno bookmarkneededresumes exactlywhere it stoppednothing leftto select

run: filter(currency="") 380,000 rows match

start

batch of 500, inside atomic()

batch committed 500 fewer rows now match

killed at row 300,000 (deploy / OOM / Ctrl-C)

the in-flight batch rolls back nothing half-applied

re-run: filter(currency="") 80,000 rows match

0 rows match — the command is a no-op

end

  • run: filter(currency="") 380,000 rows match (start)
    • → batch of 500, inside atomic() when take 500
  • batch of 500, inside atomic()
    • → batch committed 500 fewer rows now match when success
    • → killed at row 300,000 (deploy / OOM / Ctrl-C) when interrupted
  • batch committed 500 fewer rows now match
    • → batch of 500, inside atomic() when next batch
    • → 0 rows match — the command is a no-op when nothing left to select
  • killed at row 300,000 (deploy / OOM / Ctrl-C)
    • → the in-flight batch rolls back nothing half-applied when automatic
  • the in-flight batch rolls back nothing half-applied
    • → re-run: filter(currency="") 80,000 rows match when no bookmark needed
  • re-run: filter(currency="") 80,000 rows match
    • → batch of 500, inside atomic() when resumes exactly where it stopped
  • 0 rows match — the command is a no-op (end)

Command

python
with transaction.atomic():
    process(chunk)
    if not options["apply"]:
        transaction.set_rollback(True)

Assume the command will be interrupted, because it will be. Select by outcome (`filter(currency="")`) rather than by offset — the set shrinks, so a re-run resumes for free and a third run is a no-op; paginating a queryset you are modifying silently skips half the table. Commit in batches inside `atomic()`, because the batch size *is* the interruption cost, and one transaction around the whole run turns a backfill into a site-wide lock. Stream with `.iterator(chunk_size=…)`. Make dry run the default, and implement it by running the real path and refusing to persist.

4.The six jobs, and what each one needs to be safe

The roadmap names six uses: backfills, reconciliation, cleanup, imports, exports and maintenance. They are not six unrelated tasks — they are one shape (run inside the project, over a set of rows, on a schedule or on demand) with different risk profiles. A backfill changes data that already exists. Cleanup deletes. Reconciliation only compares. Knowing which of those you are writing tells you how much of the dry-run/batching/idempotency machinery you actually need.

Key features

  • Reconciliation only reads — write it first, and use it to verify the commands that write
  • A backfill derives values from data you already have, so it is deterministic and re-runnable
  • An import brings values from outside: it needs a stable key, or a re-run duplicates everything
  • Cleanup is the one you cannot undo — check what `on_delete=CASCADE` will take with it
  • Exports leave the system: the risk is disclosure, not corruption
  • Maintenance runs unattended, so its exit code and its log line are its entire interface

The six, by what they touch and what they therefore need

The six, by what they touch and what they therefore need
JobTouchesNeeds
**Backfill**existing rows, derived valuesdry-run default, batches, idempotent selection
**Reconciliation**nothing — reads and reportsa clear exit code; safe to run often
**Cleanup**deletes rowscascade audit, a `--before` window, batching
**Import**creates rows from outside datavalidation, a stable key, partial-failure policy
**Export**reads, writes a filestreaming, and an answer to "who may run this"
**Maintenance**caches, indexes, views, sessionsschedulability, alerting on non-zero exit
Six jobs, three risk profiles — and the caution each one earns

Reads only

Reconciliation

compare two sources, report differences

Export

safe for the data — but it leaves the system

Writes rows

Backfill

derives values from data you already have

Import

outside data — needs a stable key and a failure policy

Deletes, or runs unattended

Cleanup

CASCADE means one row can take a subtree

Maintenance

nobody is watching: the exit code is the interface

  • Reads only — a bug produces a wrong report, not wrong data
    • Reconciliation — compare two sources, report differences
    • Export — safe for the data — but it leaves the system
  • Writes rows — needs the full apparatus: dry run, batches, idempotency
    • Backfill — derives values from data you already have
    • Import — outside data — needs a stable key and a failure policy
  • Deletes, or runs unattended — the two you cannot inspect after the fact
    • Cleanup — CASCADE means one row can take a subtree
    • Maintenance — nobody is watching: the exit code is the interface

Command

python
call_command("reconcile_payments", since="2026-09-01")   # never Command().handle()

Six jobs, three risk levels. Reconciliation reads only — write it first and use it to verify the ones that write. Backfills and imports both write and both need the dry-run/batch/idempotency apparatus; the difference is that a backfill derives values you already have (deterministic) while an import takes them from outside (needs a stable key and a partial-failure policy). Exports are safe for your data but leave the building, so the question is disclosure. Cleanup is the dangerous one, because `on_delete=CASCADE` means one row can be a subtree — report the cascade before running it. Maintenance runs unattended, so its exit code is its entire interface.

Remember

  • `--dry-run` arrives in `handle` as `options["dry_run"]` — argparse converts dashes to underscores.
  • Never OFFSET a queryset you are modifying: fixed rows leave the set, so the next page skips unprocessed rows.
  • `self.stdout` for results, `self.stderr` for progress — so `cmd > out.txt` produces a clean file.
  • `CommandError` prints to stderr with no traceback and exits 1, or whatever `returncode` you pass.

Pro tip

  • Write the reconciliation command before the backfill. A write you cannot verify afterwards is a write you should not run.
  • Use `call_command()` to invoke one command from another — `execute()` "should not be called directly from your code".

Next up

  • File Exports and Imports
  • Multi-Tenancy
  • Auditing

Sources

  • Writing custom django-admin commands — docs.djangoproject.com/en/5.2/howto/custom-management-commands/
  • Database transactions — docs.djangoproject.com/en/5.2/topics/db/transactions/
  • Every claim checked against Django 5.2 LTS docs · checked 2026-09-05

Spotted a mistake on this page? Tell us.