Custom management commands — BaseCommand and manage.py
manage.py migrate and manage.py createsuperuser aren't special-cased — they're both instances of the exact same BaseCommand mechanism any project can add its own commands to, for anything that's a script but needs the app's models and settings loaded.
3 min read
Why a custom command instead of a standalone script
# a standalone script has to manually set up Django before it can
# import a single model — easy to get wrong, and easy to forget
import os, django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
django.setup()
from myapp.models import ArticleA plain Python script that imports a Django model has to manually configure DJANGO_SETTINGS_MODULE and call django.setup() before any model import works — manage.py already does exactly this setup for every command it runs. A custom management command gets that setup for free, plus argument parsing, help text, and a consistent place for one-off or recurring operational scripts (data backfills, scheduled cleanup jobs, import scripts) to live inside the project instead of scattered as ad-hoc scripts.
The required file location and structure
myapp/
management/
__init__.py
commands/
__init__.py
cleanup_expired_sessions.py
# myapp/management/commands/cleanup_expired_sessions.py
from django.core.management.base import BaseCommand
from myapp.models import Session
class Command(BaseCommand):
help = "Deletes session records that expired more than 30 days ago."
def handle(self, *args, **options):
deleted_count, _ = Session.objects.filter(expired_over_30_days=True).delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {deleted_count} expired sessions"))Django discovers commands purely by file location — a file at <app>/management/commands/<name>.py defining a class literally named Command (subclassing BaseCommand) becomes runnable as python manage.py <name>, with the filename (minus .py) as the command name. Both management/ and commands/ need an __init__.py to be recognized as Python packages, a common source of "command not found" when a new command file doesn't show up.
add_arguments: giving a command its own CLI flags
class Command(BaseCommand):
help = "Deletes sessions expired more than N days ago."
def add_arguments(self, parser):
parser.add_argument("--days", type=int, default=30, help="expiry threshold in days")
parser.add_argument("--dry-run", action="store_true", help="show what would be deleted, without deleting")
def handle(self, *args, **options):
days = options["days"]
queryset = Session.objects.filter(expired_days_gt=days)
if options["dry_run"]:
self.stdout.write(f"Would delete {queryset.count()} sessions")
else:
deleted_count, _ = queryset.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {deleted_count} sessions"))add_arguments(self, parser) receives a standard-library argparse parser (covered in the Python domain's command-line-arguments lesson) — every argparse feature works exactly the same here, since Django's command framework is built directly on top of it. options inside handle() is a dict of the parsed values, keyed by argument name (with dashes converted to underscores: --dry-run becomes options["dry_run"]).
self.stdout.write and self.style, not plain print()
self.stdout.write(self.style.SUCCESS("Done")) # green, when the terminal supports color
self.stdout.write(self.style.ERROR("Failed")) # red
self.stdout.write(self.style.WARNING("Skipped")) # yellowManagement commands use self.stdout.write(...) instead of print() specifically because self.stdout is a real, testable stream Django controls — a test can capture and assert against what a command wrote, which capturing print() output requires extra work to do cleanly. self.style.SUCCESS/ERROR/WARNING wrap text in ANSI color codes automatically stripped when output isn't going to an interactive terminal (a cron log file, for instance), so the same code produces clean plain text in a log and colored output in a terminal, with no conditional logic needed.
Calling a command from code: call_command
from django.core.management import call_command
call_command("cleanup_expired_sessions", days=60, dry_run=True)call_command runs a management command programmatically — from another script, a Celery task, or a test — passing arguments as keyword arguments instead of parsed CLI strings. This is the standard way to trigger a management command on a schedule (from a task queue) or to test one directly, without shelling out to manage.py as a subprocess.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. How does Django discover a custom management command?
2. Why does a custom management command get Django's app registry and settings already loaded, while a standalone script doesn't?
3. What does add_arguments(self, parser) receive?
4. Why use self.stdout.write(self.style.SUCCESS(...)) instead of print()?