Filter concepts by levelShowing all levels.

Django · Section 82

Management Commands

Level
intermediate
Read
38 min
Concepts
4

A management command is a `Command` class in `<app>/management/commands/<name>.py` — discovered by path, so both directory levels must exist and the app must be in `INSTALLED_APPS`, and a file in the wrong place produces "Unknown command" with no traceback to explain it. Two methods do everything: `add_arguments(self, parser)` declares the interface on a real `argparse` parser, and `handle(self, *args, **options)` receives the parsed values, with dashes converted to underscores. The point of the class over a loose script is that it runs *inside* the project — settings, apps, connections, ORM — so it cannot drift from the code your site actually runs, which is why backfills and reconciliation belong here rather than in `scripts/`. The second idea is that a command has two audiences. A person wants progress and a readable summary; cron, CI and `&&` can see only the exit code. Write results to `self.stdout` and progress to `self.stderr` so a redirect still yields a clean file, colour with `self.style.SUCCESS` (which degrades under `--no-color`), and raise `CommandError` to fail — because catching an exception, printing "failed" and returning exits zero, and a green build is how a broken nightly job survives for months. The third idea is the one that matters at scale: assume the command will be interrupted, because a deploy, an OOM kill or a Ctrl-C eventually will. Select rows by *outcome* rather than by offset, so the work set shrinks and a re-run resumes with no bookmark — and so that paginating a queryset you are modifying, which silently skips half the table, never arises. Commit in batches inside `atomic()`, since the batch size is exactly how much work an interruption throws away, and one transaction around a whole backfill turns it into a site-wide lock with a rollback as long as the run. Make the dry run the default and implement it by executing the real path and refusing to persist, because a separate "what I would do" branch drifts from the real one. The section closes on the roadmap's own six uses, sorted by consequence: reconciliation reads only and should be written first; backfills and imports write and need the full apparatus; exports leave the building, so the risk is disclosure; cleanup cannot be undone and `CASCADE` means one row can be a subtree; and maintenance runs unattended, so its exit code is its entire interface.

What is true here

  1. Discovery is by path — management/commands/ inside an installed app, class named Command.
  2. add_arguments is the interface and argparse validates it before handle runs.
  3. The exit code is the only thing a scheduler can see; CommandError is how you set it.
  4. Idempotent selection makes batching safe, and batching makes interruption cheap.
  5. Six jobs, three risk levels — and cleanup is the one with a cascade behind it.

What you will be able to do

  • Write a command whose `--help` describes it completely
  • Fail in a way cron and CI actually notice
  • Backfill 380,000 rows without a long lock, and resume after a kill
  • Tell which of the six jobs you are writing, and how careful it has to be
One invocation, and the four gates between the shell and a committed row
wrong pathvalidbad inputdry run--applynext batchfailure

manage.py backfill_currency --apply

discovered by path, not by registration

"Unknown command"

wrong directory, or app not in INSTALLED_APPS

add_arguments → argparse

types converted, unknown flags rejected — before handle runs

system + migration checks

requires_system_checks · requires_migrations_checks

handle(**options)

options["dry_run"], options["batch_size"]

filter(currency="")

selected by outcome — the set shrinks as work commits

atomic() per batch

bounded lock, bounded loss

set_rollback(True)

no --apply: the real path ran, nothing persists

Committed

those rows now leave the queryset

CommandError

stderr, no traceback, exit 1 — cron sees this

  • manage.py backfill_currency --apply — discovered by path, not by registration
    • leads to add_arguments → argparse
    • on error, leads to "Unknown command" (wrong path)
  • "Unknown command" — wrong directory, or app not in INSTALLED_APPS
  • add_arguments → argparse — types converted, unknown flags rejected — before handle runs
    • leads to system + migration checks (valid)
    • on error, leads to CommandError (bad input)
  • system + migration checks — requires_system_checks · requires_migrations_checks
    • leads to handle(**options)
  • handle(**options) — options["dry_run"], options["batch_size"]
    • leads to filter(currency="")
  • filter(currency="") — selected by outcome — the set shrinks as work commits
    • leads to atomic() per batch
  • atomic() per batch — bounded lock, bounded loss
    • leads to set_rollback(True) (dry run)
    • leads to Committed (--apply)
    • on error, leads to CommandError (failure)
  • set_rollback(True) — no --apply: the real path ran, nothing persists
  • Committed — those rows now leave the queryset
    • leads to filter(currency="") (next batch)
  • CommandError — stderr, no traceback, exit 1 — cron sees this

The class, and its interface

Where the file goes, and how argparse validates input before `handle` ever runs.

`BaseCommand`, and the two methods that make one

coreintermediate

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.

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.

python
# <app>/management/commands/backfill_currency.py
class Command(BaseCommand):
    def add_arguments(self, parser): ...
    def handle(self, *args, **options): ...

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.

billing/management/commands/backfill_currency.pypython
from django.core.management.base import BaseCommand, CommandError

from billing.models import Order


class Command(BaseCommand):
    help = "Set Order.currency from the customer's country for rows missing it."

    # Refuse to run against a database whose migrations do not match the code.
    requires_migrations_checks = True

    def add_arguments(self, parser):
        parser.add_argument(
            "ids",
            nargs="*",
            type=int,
            help="Order ids to process. Omit to process every affected row.",
        )
        parser.add_argument(
            "--apply",
            action="store_true",
            help="Write the changes. Without this the command reports only.",
        )
        parser.add_argument(
            "--batch-size", type=int, default=500, help="Rows per transaction."
        )

    def handle(self, *args, **options):
        queryset = Order.objects.filter(currency="")
        if options["ids"]:
            queryset = queryset.filter(pk__in=options["ids"])

        if not queryset.exists():
            self.stdout.write("Nothing to do.")
            return

        if options["batch_size"] < 1:
            raise CommandError("--batch-size must be at least 1")

        self.run(queryset, apply=options["apply"], size=options["batch_size"])
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

python
def handle(self, *args, **options):
    dry_run = "--dry-run" not in sys.argv     # invisible to --help
    limit = int(sys.argv[-1])                 # crashes on anything else

Better

python
def add_arguments(self, parser):
    parser.add_argument("--apply", action="store_true")
    parser.add_argument("--limit", type=int, default=1000)

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.

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.

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

Together

python
def add_arguments(self, parser):
    parser.add_argument("ids", nargs="*", type=int, help="Order ids; omit for all")
    parser.add_argument("--apply", action="store_true", help="Write changes")
    parser.add_argument("--batch-size", type=int, default=500)

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;`

Together

python
class Command(BaseCommand):
    help = "Backfill Order.currency from the customer's country"
    requires_migrations_checks = True   # refuse to guess against a stale schema

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

Advertisement

What the caller sees

Two streams, three styles, and the exit code that is the only signal a scheduler has.

Output, errors and the exit code something else is reading

standardintermediate

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.

Think of it as

A management command has two audiences and they want different things. A person wants progress, reassurance and a readable summary. A machine — cron, a CI step, a Kubernetes Job, the shell operator in `&&` — wants an exit code, and it is the only thing it can actually see. Almost every bad command experience comes from serving only the first. The exit code is the contract. Zero means success, non-zero means failure, and `CommandError` is the mechanism: Django catches it, formats it to stderr, and exits with `returncode`, which defaults to 1. A command that catches its own exceptions, prints "failed", and returns normally exits zero — so the cron job that wraps it reports success, the alert never fires, and the failure is discovered days later by a human noticing missing data. That is the single highest-cost mistake in this section, because it is invisible in every test that only reads output. The stream split is the same contract at a finer grain. Progress and diagnostics belong on stderr, results belong on stdout, and keeping them apart is what lets `command > data.csv` work while the operator still sees what is happening. Progress reporting itself has a rule worth stating: report against a denominator. "Processed 12,000 rows" tells a person nothing about whether to wait; "12,000 / 380,000, batch 24" tells them how long. And for long runs, log rather than only print — a progress line lost to a closed terminal cannot answer "where did it get to?" the next morning, whereas a structured log line with the same fields can.

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

What we're doing: A long-running command that reports progress against a denominator, keeps results and diagnostics on separate streams, and exits with a code a wrapper can act on.

billing/management/commands/reconcile.pypython
import logging
from django.core.management.base import BaseCommand, CommandError

log = logging.getLogger(__name__)


class Command(BaseCommand):
    help = "Reconcile settled payments against the provider's ledger."

    def handle(self, *args, **options):
        total = Payment.objects.settled().count()
        if not total:
            # Distinguishable from a real failure by its exit code.
            raise CommandError("no settled payments in the window", returncode=2)

        mismatched = 0
        for done, payment in enumerate(Payment.objects.settled().iterator(), start=1):
            try:
                if not reconcile(payment):
                    mismatched += 1
                    # The RESULT goes to stdout: this is what a pipe wants.
                    self.stdout.write(str(payment.reference))
            except ProviderUnavailable as exc:
                raise CommandError(f"provider unavailable after {done} rows") from exc

            if done % 500 == 0:
                # PROGRESS goes to stderr, so `command > mismatches.txt` still shows it.
                self.stderr.write(f"  {done:,} / {total:,}")
                log.info("reconcile progress",
                         extra={"done": done, "total": total, "mismatched": mismatched})

        self.stderr.write(
            self.style.SUCCESS(f"done: {mismatched:,} mismatched of {total:,}")
        )
10–14
Counting first gives progress a denominator. "Processed 12,000" tells nobody whether to wait; "12,000 / 380,000" does. The custom `returncode=2` lets a wrapper tell "nothing to do" from "broke".
18–21
The result — the references that did not match — goes to stdout, so `manage.py reconcile > mismatches.txt` produces a clean file with no progress lines in it.
22–23
An unavailable provider is not something this command can resolve, so it stops with a non-zero exit rather than continuing and reporting a mismatch count that is missing everything after row `done`.
27
Progress on stderr is what keeps the redirect above usable. Putting it on stdout would interleave diagnostics into the data file.
28–29
The same numbers logged as fields, because a progress line printed to a terminal that has since closed cannot answer "where did it get to?" tomorrow morning.
32–34
`self.style.SUCCESS` colours the summary for a person and returns the plain string under `--no-color`, so a log capture is not full of escape sequences.

Why this works: A person watching sees progress and a coloured summary, a redirect captures only data, and cron gets three distinct exit codes it can branch on.

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

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

Together

python
raise CommandError("provider unreachable", returncode=75)
# 75 = EX_TEMPFAIL by convention: a wrapper can choose to retry this

Remember: 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.

See also: basecommand arguments and flags · dry run batching and idempotency · json logs and context fields

Advertisement

Safe to interrupt, safe to re-run

Idempotent selection, batched transactions, and a dry run that cannot lie.

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

coreadvanced

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.

Think of it as

Design the command around the assumption that it will be interrupted, because at production scale it will be — a deploy, an OOM kill, a network blip, someone pressing Ctrl-C. Once you accept that, the three properties stop looking like polish and start looking like the minimum. Idempotency is the one to design first, and the reliable way to get it is to select by *outcome* rather than by position. A query for "rows that still need the work" — `filter(currency="")` — naturally shrinks as the command progresses, so re-running it after a crash picks up exactly where it stopped, with no bookmark to store and nothing to skip. A command driven by an offset or a hard-coded id range has neither property: re-running repeats work, and resuming needs state you have to keep correct yourself. Transaction scope is the second decision, and it is a genuine trade. One transaction around everything gives you all-or-nothing, and on a large table it also gives you a hours-long write lock, a bloated undo log, and a rollback at the end that can take as long as the work did. One transaction per row gives you neither locking problems nor any grouping guarantee. A batch — a few hundred to a few thousand rows in one `atomic()` block — is the usual answer, and the batch size is exactly the knob controlling how much work an interruption costs you. The dry run is the third, and the important detail is *how* it is implemented. Printing what the command would do, computed by a separate code path, is a dry run that can lie: the reporting path and the writing path drift, and the one you tested is not the one that runs. The honest version executes the real path and refuses to persist — either by gating each write on the flag, or by running inside a transaction that is deliberately rolled back. And whichever you choose, the default should be the safe one. A command whose default is "write" is a command someone runs by accident.

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

What we're doing: A backfill that streams rows, commits in bounded batches, dry-runs by default through the real code path, and can be re-run after any interruption.

billing/management/commands/backfill_currency.pypython
from itertools import islice
from django.db import transaction


class Command(BaseCommand):
    help = "Set Order.currency from the customer's country. Safe to re-run."

    def handle(self, *args, **options):
        apply_changes = options["apply"]
        size = options["batch_size"]

        # Selected by OUTCOME, not by offset. This set shrinks as work is
        # committed, so an interrupted run resumes with no bookmark at all.
        queryset = (
            Order.objects.filter(currency="")
            .select_related("customer")
            .order_by("pk")
        )
        total = queryset.count()

        # .iterator() streams with a server-side cursor and does not fill the
        # result cache — a plain loop would load all 380,000 rows into memory.
        rows = queryset.iterator(chunk_size=size)
        done = 0

        while chunk := list(islice(rows, size)):
            with transaction.atomic():
                for order in chunk:
                    order.currency = CURRENCY_BY_COUNTRY[order.customer.country]
                Order.objects.bulk_update(chunk, ["currency"])

                if not apply_changes:
                    # The real path ran. Nothing persists.
                    transaction.set_rollback(True)

            done += len(chunk)
            self.stderr.write(f"  {done:,} / {total:,}")

        verb = "would update" if not apply_changes else "updated"
        self.stderr.write(self.style.SUCCESS(f"{verb} {done:,} rows"))
13–18
The whole idempotency argument is in this filter. Rows leave the set as they are fixed, so run two starts where run one stopped — and a third run does nothing, because nothing matches.
21
`.iterator()` uses a server-side cursor on PostgreSQL and does not populate the result cache. Without it the queryset materialises every matching row before the first one is touched.
26–29
One `atomic()` per batch. An interruption loses at most `size` rows of work and holds a lock for one batch, not for the whole run.
31–33
The dry run goes through the identical code path and is undone at the last moment. A separate "report what I would do" branch drifts from the real one, and then the run you tested is not the run that happens.
35
Progress on stderr against a denominator that was counted up front — the only form of progress a person can act on.

Why this works: The default run writes nothing while exercising the real path, memory stays flat over 380,000 rows, an interruption costs one batch, and re-running is always safe.

Paginating a queryset you are modifying

Wrong

python
for offset in range(0, total, 500):
    for order in Order.objects.filter(currency="")[offset:offset + 500]:
        fix(order)          # each fixed row leaves the queryset…
                            # …so the next OFFSET skips 500 unprocessed rows

Better

python
while chunk := list(Order.objects.filter(currency="")[:500]):
    for order in chunk:
        fix(order)          # always take the first 500 of what REMAINS

What you see: The command reports processing every row, and a later audit finds roughly half of them untouched — with no error and no pattern that is obvious from the logs.

Why: `OFFSET` counts rows in the *current* result set, and the update removes rows from that set. After the first batch of 500 is fixed, those rows no longer match, so everything shifts down by 500 — and `OFFSET 500` lands 500 rows further on than intended, skipping the ones that moved into the gap. Half the table is silently missed. Taking the first N of what still matches has no offset to be invalidated, and terminates naturally when nothing matches.

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)

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

Together

python
for chunk in batched(queryset.iterator(chunk_size=500), 500):
    with transaction.atomic():          # one bounded unit
        for row in chunk:
            apply(row)

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

Together

python
with transaction.atomic():
    process(queryset)                    # the real path, unmodified
    if not apply:
        transaction.set_rollback(True)   # nothing persists

Remember: 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.

See also: basecommand arguments and flags · what commands are for · data migrations and work that must be resumable

Advertisement

The six jobs

Backfills, reconciliation, cleanup, imports, exports and maintenance — by consequence.

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

standardintermediate

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.

Think of it as

Sort the six by what happens if the command is wrong, and the right amount of caution falls out. Reconciliation is the safe end: it reads two sources and reports the differences, so a bug produces a wrong report rather than wrong data. That makes it the natural *first* command to write for any integration, and the natural companion to the risky ones — a backfill you cannot verify afterwards is a backfill you should not run. Exports are nearly as safe, with one caveat that is easy to miss: they leave the system, so the risk is not corruption but disclosure, and the question is who can run them and what ends up in the file. Imports and backfills sit in the middle. Both write, both usually touch a lot of rows, and both need the full apparatus — a dry run that is the default, batched transactions, idempotent selection, and a report of what changed. The distinction between them is where the data comes from: a backfill derives new values from data you already have, so it is deterministic and re-runnable; an import brings in values from outside, so it has to answer what happens when row 4,000 of 10,000 is invalid, and it needs a stable key if re-running is not to duplicate everything. Cleanup is the dangerous end, because deletion is the one operation you cannot dry-run *after the fact*. Cascades are the specific trap: Django's default `on_delete=CASCADE` means deleting what looks like one row can remove a subtree, and the count you get back is the only warning you will get. Maintenance is the odd one out — refreshing a materialised view, rebuilding a search index, expiring sessions — because the work is usually cheap and repeatable, but it runs on a schedule, which means nobody is watching. That makes the exit code and the log line the whole of its interface, and it is why a maintenance command that fails silently can be broken for months.

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

What we're doing: A cleanup command that shows what a delete will actually take before it takes it, with a required window and batched deletion.

audit/management/commands/purge_events.pypython
class Command(BaseCommand):
    help = "Delete audit events older than the retention window."

    def add_arguments(self, parser):
        # Required, with no default. A cleanup whose window defaults to
        # something is a cleanup somebody runs by accident.
        parser.add_argument("--before", type=parse_date, required=True)
        parser.add_argument("--apply", action="store_true")
        parser.add_argument("--batch-size", type=int, default=1000)

    def handle(self, *args, **options):
        queryset = AuditEvent.objects.filter(occurred_at__lt=options["before"])

        # collect() walks the cascade WITHOUT deleting, so the report below
        # includes every related row this delete would remove.
        collector = Collector(using=queryset.db)
        collector.collect(list(queryset[:1]))
        for model, instances in collector.data.items():
            self.stderr.write(f"  would also touch {model.__name__}")

        total = queryset.count()
        if not options["apply"]:
            self.stdout.write(f"would delete {total:,} audit events")
            return

        deleted = 0
        while ids := list(queryset.values_list("pk", flat=True)[: options["batch_size"]]):
            with transaction.atomic():
                count, per_model = AuditEvent.objects.filter(pk__in=ids).delete()
            deleted += count
            self.stderr.write(f"  {deleted:,} / {total:,}  {per_model}")
7
`required=True` with no default. Every other job here can have a sensible default; a delete window cannot, because the sensible default for "how much to delete" does not exist.
12
The filter is the whole safety story. `occurred_at__lt` on an indexed column keeps each batch cheap, and the set shrinks as batches commit — the same idempotent selection a backfill uses.
16–19
Django's own `Collector` walks the cascade without performing the delete, so the dry run reports the related models a `CASCADE` would take. That subtree is what makes cleanup the highest-risk job of the six.
27–29
Deleting by a batch of primary keys keeps each transaction and each lock bounded. `.delete()` returns both the total and a per-model breakdown, which is what the progress line prints — you can see the cascade actually happening.

Why this works: The default run reports the row count *and* the cascade, the window must be stated explicitly, and the real delete proceeds in bounded transactions that can be interrupted safely.

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

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

Together

bash
python manage.py reconcile_payments --since 2026-09-01   # read-only: run it first
python manage.py backfill_currency --apply                # then the write
python manage.py reconcile_payments --since 2026-09-01   # and verify

Remember: 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.

See also: dry run batching and idempotency · output errors and exit codes · imports validation and partial failure

Advertisement