How do you write a custom Django management command, such as a nightly job that deactivates expired coupons in a shop app?
answer
- a directory inside the app
- file name becomes the command name
- a class named Command
- argparse via add_arguments
- self.stdout, self.style, CommandError
basics
~10 sCreate shop/management/commands/expire_coupons.py defining Command(BaseCommand): declare options in add_arguments(parser), do the work in handle() using the parsed options, write through self.stdout with self.style, and raise CommandError on failure.
solid answer
~40 sInside an installed app, add `management/commands/`; every module there whose name does not start with an underscore becomes a command named after the file, so `expire_coupons.py` gives `python manage.py expire_coupons`. The module defines `class Command(BaseCommand)` with a `help` string, an `add_arguments(parser)` method that receives an `argparse` parser, and a `handle(*args, **options)` method that does the work, reading options by their `dest` name (`--dry-run` arrives as `options["dry_run"]`). Output goes through `self.stdout.write()`, optionally wrapped in `self.style.SUCCESS` or `self.style.WARNING`, so it can be redirected and colour can be disabled. Failures raise `CommandError`, which the command line prints to stderr and turns into a non-zero exit status (1 unless `returncode` says otherwise).
code
python · 30 lines# shop/management/commands/expire_coupons.py
from datetime import timedelta
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from shop.models import Coupon
class Command(BaseCommand):
help = "Deactivate coupons whose expiry time has passed."
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Only report how many coupons would expire.",
)
parser.add_argument("--grace-hours", type=int, default=0)
def handle(self, *args, **options):
if options["grace_hours"] < 0:
raise CommandError("--grace-hours must be zero or positive.")
cutoff = timezone.now() - timedelta(hours=options["grace_hours"])
expired = Coupon.objects.filter(is_active=True, expires_at__lt=cutoff)
if options["dry_run"]:
self.stdout.write(f"{expired.count()} coupon(s) would expire.")
return
updated = expired.update(is_active=False)
self.stdout.write(self.style.SUCCESS(f"Expired {updated} coupon(s)."))go deeper
Recall the layout, management/commands/<name>.py with a class Command(BaseCommand) that implements handle(), and that the file name is the command name.
Explain add_arguments and dest names, self.stdout versus print, self.style roles, and how CommandError becomes stderr output and exit status 1.
Show that you design commands to be idempotent, quiet under -v 0, honest in their exit status and safe to re-run after a partial failure.
Decide which operational jobs belong in management commands versus a task queue or deploy tooling, and keep them versioned and reviewed with the code they operate on.
## Where the file goes A **management command** is a Python module that Django discovers inside an installed app: ```text shop/ __init__.py models.py management/ __init__.py commands/ __init__.py _dates.py expire_coupons.py ``` - Every module in `management/commands/` becomes a command named after the file: `expire_coupons.py` is `python manage.py expire_coupons`. - Modules whose names **start with an underscore** (`_dates.py`) are skipped, which is where shared helpers go. - The app must be in `INSTALLED_APPS`; discovery scans only installed apps. - If two apps define the same command name, the app **listed earlier** in `INSTALLED_APPS` wins, and any app's command overrides a built-in one of the same name. ## The Command class The module must define a class called `Command` that subclasses `django.core.management.base.BaseCommand` (or one of its subclasses, `AppCommand` and `LabelCommand`). | Member | Role | |---|---| | `help` | One-line description shown by `manage.py help expire_coupons` | | `add_arguments(parser)` | Declares positional and optional arguments on an `argparse` parser | | `handle(*args, **options)` | The work; the only method you must implement | | `self.stdout` / `self.stderr` | Output wrappers that append a newline and can be redirected | | `self.style` | Colour roles such as `SUCCESS`, `WARNING`, `ERROR`, `NOTICE` | | `CommandError` | The exception that signals a clean, reportable failure | | `requires_system_checks` | Which system checks run first; defaults to all (`"__all__"`) | ## Arguments `add_arguments` receives a `CommandParser`, a subclass of `argparse.ArgumentParser`, so everything `argparse` does works: positional arguments, `type=int`, `choices`, `nargs`, `action="store_true"`. Each option arrives in `handle` under its **`dest`** name, which `argparse` derives from the long flag with dashes turned into underscores: `--dry-run` becomes `options["dry_run"]`. Every command also accepts the **base options**: `--verbosity/-v` (0–3, default 1), `--settings`, `--pythonpath`, `--traceback`, `--no-color`, `--force-color`, and `--skip-checks` when the command runs checks. Reading `options["verbosity"]` lets your command stay quiet under `-v 0`. ## Output and failure 1. Write with **`self.stdout.write(...)`**, not `print()`. The wrapper adds the line ending for you and, more importantly, can be swapped for a buffer when the command is called from code, which is what makes it testable. 2. Wrap messages in **`self.style.SUCCESS(...)`**, `self.style.WARNING(...)` or `self.style.ERROR(...)` for colour. `--no-color` turns the styles into plain text. 3. Raise **`CommandError("...")`** for failures you can explain. From the command line it is caught, printed to stderr as `CommandError: <message>`, and the process exits with status `1`, or with the `returncode` you pass to `CommandError`. `--traceback` shows the full stack instead. 4. Any other exception is not caught; Python prints its traceback and the process exits non-zero. ## What runs around handle() Before `handle()` runs, `BaseCommand.execute()` runs the **system checks** selected by `requires_system_checks`; a check error stops the command. If `handle()` returns a string, it is written to stdout. Commands run in the database's default autocommit mode — nothing wraps them in a transaction for you — and the command-line path closes database connections when it finishes. ## AppCommand and LabelCommand Two subclasses save boilerplate for common shapes: - **`AppCommand`** takes one or more app labels on the command line and calls `handle_app_config(app_config, **options)` once per app — `sqlsequencereset` is built this way. - **`LabelCommand`** takes arbitrary labels and calls `handle_label(label, **options)` once per label — `findstatic` from `django.contrib.staticfiles` is an example. With either, you implement the per-item method instead of `handle()`. ## Common mistakes - Forgetting to add the app to `INSTALLED_APPS`, then wondering why the command is "unknown". - Naming the module with a leading underscore, which hides it on purpose. - Implementing `__init__` without calling `super().__init__()`, which leaves `stdout`, `stderr` and `style` unset. - Returning `False` or an error string from `handle()` to signal failure: a returned string is simply printed, and the exit status stays 0. - Putting the business logic inside `handle()` itself. A thin command that calls a plain function keeps the logic reusable from views, tasks and tests. ## Reading the coupon example The example below computes one cutoff, supports a `--dry-run` that only counts, validates its own option with `CommandError`, and performs a single set-based `update()` so that running it twice does no harm. Scheduling it nightly is the job of cron or another scheduler outside the command.
- What should a command do when an option value is invalid, and why not call sys.exit() yourself?Raise `CommandError` with a clear message. From the command line Django prints it to stderr and exits with status 1 (or the `returncode` you give it); from `call_command()` it propagates as an ordinary exception the caller can catch or assert on. Calling `sys.exit()` inside `handle()` would kill a test run or any Python caller that invoked the command in-process.
- How do you replace a third-party app's management command with your own version?Create a command module with the same name in one of your apps and list that app before the third-party app in `INSTALLED_APPS`. Django registers core commands first and then scans installed apps in reverse, so the earlier app's command overrides the later one. Your module can import and subclass the original `Command` to keep most of its behaviour.
saying these in an interview costs you the question
- Commands must be registered in a setting before manage.py finds them
- The command name comes from a name attribute on the Command class
- Use print() for output since stdout is the same thing
- Signal failure by returning False from handle()
- Django wraps every management command in a database transaction