A custom management command lets you run your own project logic from the command line with python manage.py <command>. Because the command executes inside Django's full application context - your settings, the ORM, models, apps, and signals are all loaded - it is the right tool for cron jobs, scheduled maintenance, one-off data migrations, bulk imports and exports, backfills, and any admin task you would otherwise hack together as a loose script.
Reach for a management command whenever a task needs your models or settings and should be triggered from a shell, a scheduler (cron, Celery Beat, or a systemd timer), or a deploy pipeline. The examples below use Django 5.x on Python 3.11+ and the modern BaseCommand API.
If you build and operate Django systems day to day, the team at MicroPyramid has shipped 50+ projects over 12+ years where commands like these run the nightly jobs and data pipelines behind production apps.
Where management commands live
Django discovers commands by scanning a management/commands/ directory inside any installed app. The layout is strict, and getting it wrong is the single most common reason a brand-new command is reported as not found:
myproject/
myapp/
__init__.py
models.py
management/
__init__.py # required: makes 'management' a package
commands/
__init__.py # required: makes 'commands' a package
deactivate_inactive_users.py # -> manage.py deactivate_inactive_usersTwo rules to remember:
- You need an empty
__init__.pyin both themanagement/andmanagement/commands/directories. Miss either one and Django will not see the package, so you will get anUnknown commanderror. - The command name is the file name.
deactivate_inactive_users.pybecomespython manage.py deactivate_inactive_users. Files whose names start with an underscore are ignored, which is handy for shared helper modules.
The app must also be listed in INSTALLED_APPS, otherwise its commands are never discovered.
The BaseCommand skeleton
Every command is a class named Command that subclasses BaseCommand. Set a help string (shown by manage.py help <command>) and put your logic in handle():
# myapp/management/commands/deactivate_inactive_users.py
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = "Deactivate users who have not logged in for a given number of days."
def handle(self, *args, **options):
self.stdout.write("Running deactivate_inactive_users...")
# your logic goes here
self.stdout.write(self.style.SUCCESS("Done."))Run it with python manage.py deactivate_inactive_users. That is a complete, working command.
Adding arguments and flags
Override add_arguments() to accept input. Django uses Python's standard argparse under the hood, so positional arguments, typed options, choices, and boolean flags all behave the way they do in any CLI. The parsed values arrive in the options dict inside handle():
class Command(BaseCommand):
help = "Demonstrates the common argparse argument patterns."
def add_arguments(self, parser):
# Positional (required) argument
parser.add_argument("username", type=str, help="User to update")
# Optional value with a type and a default
parser.add_argument("--days", type=int, default=90, help="Inactivity window in days")
# Restrict to a fixed set of values
parser.add_argument("--role", choices=["admin", "staff", "member"], default="member")
# Accept one or more values: --tags a b c
parser.add_argument("--tags", nargs="+", default=[])
# Boolean flag: present -> True, absent -> False
parser.add_argument("--dry-run", action="store_true", help="Report only; change nothing")
def handle(self, *args, **options):
username = options["username"]
days = options["days"]
dry_run = options["dry_run"] # note: --dry-run is read as options["dry_run"]
self.stdout.write(f"username={username} days={days} dry_run={dry_run}")Common argument patterns at a glance:
| You want | add_argument(...) |
Access in handle() |
|---|---|---|
| Required positional | parser.add_argument("name") |
options["name"] |
| Optional value | parser.add_argument("--days", type=int, default=90) |
options["days"] |
| Boolean flag | parser.add_argument("--dry-run", action="store_true") |
options["dry_run"] |
| Fixed choices | parser.add_argument("--role", choices=[...]) |
options["role"] |
| Many values | parser.add_argument("--tags", nargs="+") |
options["tags"] |
argparse turns dashes into underscores, so --dry-run is read as options["dry_run"].
Writing output the right way
Inside a command, do not use print(). Write through self.stdout.write() and self.stderr.write() so output respects the --verbosity and --no-color flags and can be redirected cleanly by callers. Wrap messages in the built-in styles to colour them:
def handle(self, *args, **options):
self.stdout.write(self.style.SUCCESS("Success: 12 records updated"))
self.stdout.write(self.style.WARNING("Warning: 3 records skipped"))
self.stderr.write(self.style.ERROR("Error: could not reach the API"))
# Respect --verbosity (0=quiet, 1=normal, 2=verbose, 3=very verbose)
if options["verbosity"] >= 2:
self.stdout.write("Detailed progress information...")Available styles include SUCCESS, WARNING, ERROR, NOTICE, and HTTP_INFO. Colour is disabled automatically when output is piped to a file or when --no-color is passed, so you never produce garbled escape codes in a log.
Errors, exit codes, and transactions
When something goes wrong, raise CommandError instead of calling sys.exit() or letting a raw exception bubble up. Django prints the message to stderr and exits with a non-zero status code (1) - exactly what a cron job or CI step needs to detect failure:
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from myapp.models import Account
class Command(BaseCommand):
help = "Close an account by id."
def add_arguments(self, parser):
parser.add_argument("account_id", type=int)
def handle(self, *args, **options):
try:
account = Account.objects.get(pk=options["account_id"])
except Account.DoesNotExist:
raise CommandError(f"Account {options['account_id']} does not exist")
# Wrap data-changing work so a mid-run failure rolls everything back.
with transaction.atomic():
account.close()
self.stdout.write(self.style.SUCCESS(f"Closed account {account.pk}"))A few more BaseCommand knobs worth knowing:
- Wrap any command that writes to the database in
@transaction.atomic(or awith transaction.atomic():block) so a failure partway through does not leave half-applied changes. - Set
requires_migrations_checks = Trueon the class to warn the operator when there are unapplied migrations before the command runs. - Every command already accepts
--verbosity,--no-color,--settings, and--pythonpathfor free - you do not declare them yourself.
A complete example: deactivate inactive users
Here is a realistic, production-shaped command that ties it together. It deactivates users who have not logged in for N days, supports a --dry-run flag so you can preview the impact safely, and wraps the write in a transaction:
# myapp/management/commands/deactivate_inactive_users.py
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils import timezone
class Command(BaseCommand):
help = "Deactivate users inactive for more than --days days."
def add_arguments(self, parser):
parser.add_argument("--days", type=int, default=90, help="Inactivity window (default: 90)")
parser.add_argument("--dry-run", action="store_true", help="Show what would change, but do not save")
def handle(self, *args, **options):
days = options["days"]
dry_run = options["dry_run"]
cutoff = timezone.now() - timedelta(days=days)
User = get_user_model()
stale = User.objects.filter(is_active=True, last_login__lt=cutoff)
count = stale.count()
if dry_run:
self.stdout.write(self.style.WARNING(f"[dry-run] Would deactivate {count} user(s) inactive since {cutoff:%Y-%m-%d}"))
return
with transaction.atomic():
updated = stale.update(is_active=False)
self.stdout.write(self.style.SUCCESS(f"Deactivated {updated} user(s) inactive since {cutoff:%Y-%m-%d}"))Use it like this:
# Preview first - changes nothing
python manage.py deactivate_inactive_users --days 60 --dry-run
# Then run for real
python manage.py deactivate_inactive_users --days 60
A --dry-run flag like this is worth adding to any command that deletes or bulk-updates data: it turns a risky one-shot job into something a teammate can review before it ever touches the database.
Calling a command from code and scheduling it
You can invoke any management command programmatically with call_command() - from another command, a view, a test, or a background task. Pass options as keyword arguments:
from io import StringIO
from django.core.management import call_command
# Same as: python manage.py deactivate_inactive_users --days 30 --dry-run
call_command("deactivate_inactive_users", days=30, dry_run=True)
# Capture output instead of printing it
buffer = StringIO()
call_command("deactivate_inactive_users", "--days", "30", stdout=buffer)
print(buffer.getvalue())To run a command on a schedule, point your scheduler at the same manage.py entry point:
- cron or systemd timer - call
python manage.py deactivate_inactive_users --days 90directly from the crontab or timer unit. Simple and dependency-free for periodic maintenance. - Celery Beat - if you already run Celery, wrap the command in a task with
call_command(...)and schedule that task. See our guide on creating periodic tasks in Django with Celery Beat for the full setup.
Because a management command is plain Python, the same code runs identically from the shell, from cron, and from a Celery task, which keeps your scheduled jobs easy to reason about and test.
Testing your management command
Commands are straightforward to test: call them with call_command() and assert on the captured output or the resulting database state. No HTTP client or subprocess is needed.
from datetime import timedelta
from io import StringIO
from django.contrib.auth import get_user_model
from django.core.management import call_command
from django.test import TestCase
from django.utils import timezone
class DeactivateInactiveUsersTests(TestCase):
def test_deactivates_stale_users(self):
User = get_user_model()
old = timezone.now() - timedelta(days=200)
user = User.objects.create_user("stale", last_login=old, is_active=True)
out = StringIO()
call_command("deactivate_inactive_users", days=90, stdout=out)
user.refresh_from_db()
self.assertFalse(user.is_active)
self.assertIn("Deactivated 1 user", out.getvalue())
def test_dry_run_changes_nothing(self):
User = get_user_model()
old = timezone.now() - timedelta(days=200)
user = User.objects.create_user("stale", last_login=old, is_active=True)
call_command("deactivate_inactive_users", days=90, dry_run=True)
user.refresh_from_db()
self.assertTrue(user.is_active)Wrapping up
Custom management commands are the cleanest way to put project logic on the command line: keep each one small, validate input through add_arguments(), write output via self.stdout and self.style, fail loudly with CommandError, and guard data changes with a transaction plus a --dry-run switch. The same patterns scale from a quick one-off data fix to the scheduled jobs that keep a production system healthy.
If you want a hand designing the data pipelines, scheduled jobs, and Python tooling behind a Django product, MicroPyramid has done it across 50+ projects in 12+ years - reach out and we will help you ship it reliably.
Frequently Asked Questions
Why does Django say Unknown command or command not found?
Almost always a packaging problem. Make sure there is an empty __init__.py in both the management/ and management/commands/ directories, that the command file sits directly inside management/commands/, and that the app is listed in INSTALLED_APPS. The command name is the file name without .py, and files starting with an underscore are skipped.
How do I add arguments and flags to a command?
Override add_arguments(self, parser) and call parser.add_argument(...). Django uses Python's argparse, so you get positional arguments, typed options like type=int, defaults, choices, multiple values with nargs="+", and boolean flags with action="store_true". The parsed values arrive in the options dict passed to handle().
How do I add a --dry-run option?
Declare parser.add_argument("--dry-run", action="store_true"), then read options["dry_run"] in handle() (argparse converts the dash to an underscore). When it is true, log what would happen and return before writing anything. It is the safest way to preview destructive or bulk-update commands.
How do I print coloured success and error output?
Use self.stdout.write(self.style.SUCCESS("...")) for success and self.style.WARNING or self.style.ERROR for problems, instead of print(). Writing through self.stdout and self.stderr respects --verbosity and --no-color, and Django disables colour automatically when output is piped.
How do I signal failure from a management command?
Raise CommandError with a message. Django prints it to stderr and exits with status code 1, so cron jobs, deploy scripts, and CI can detect the failure. Avoid sys.exit(), and do not let raw tracebacks escape for expected error conditions.
Can I run a management command on a schedule or from code?
Yes. From code, call call_command("your_command", arg=value). To schedule it, run python manage.py your_command from cron or a systemd timer, or wrap it in a Celery task and schedule it with Celery Beat. The same command code runs identically from the shell, a scheduler, and your tests.