Custom Management Commands

Reviewed & published by Brayan K

A custom management command is a Python class you add to your app so you can run your own task from the command line with python manage.py, just like Django's built-in commands.

Part of the free Django course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

In this lesson you'll subclass BaseCommand, implement handle, declare command-line arguments with add_arguments, print coloured output with self.stdout.write and self.style, and schedule the command to run automatically with cron.

What You'll Learn in This Lesson

1 BaseCommand and the handle() Method

A command is a class named Command that subclasses BaseCommand and implements handle(self, *args, **options) — that method is the entry point Django calls. The file's name becomes the command name. The class also carries a help string shown by manage.py help.

# myapp/management/commands/greet.py
from django.core.management.base import BaseCommand

class Command(BaseCommand):
    help = "Greets the world or a named person"

    def handle(self, *args, **options):
        self.stdout.write("Hello, World!")

# The folder layout that makes Django find it:
# myapp/
#   management/
#     __init__.py
#     commands/
#       __init__.py
#       greet.py     <-- run with: python manage.py greet
# A management command is a class with a handle() method. Django finds it,
# parses the command line, and calls handle(*args, **options). This BaseCommand
# stand-in shows that exact lifecycle with plain Python.

class BaseCommand:
    help = ""
    def handle(self, *args, **options):
        raise NotImplementedError("subclasses must implement handle()")

class Command(BaseCommand):
    help = "Greets the world or a named person"

    def handle(self, *args, **options):
        name = options.get("name", "World")
        return f"Hello, {name}!"

# Django does this for you when you run: python manage.py greet
cmd = Command()
print("help text:", cmd.help)
print(cmd.handle(name="World"))
print(cmd.handle(name="Ada"))

# EXPECTED:
# help text: Greets the world or a named person
# Hello, World!
# Hello, Ada!

# ✅ Expected output:
# help text: Greets the world or a named person
# Hello, World!
# Hello, Ada!

2 Arguments with add_arguments()

To accept input, implement add_arguments(self, parser). Django hands you a standard argparse parser; you register positional arguments and --optional flags on it. After parsing, the values land in the options dictionary your handle receives.

# myapp/management/commands/seed_users.py
from django.core.management.base import BaseCommand

class Command(BaseCommand):
    help = "Create some placeholder users"

    def add_arguments(self, parser):
        parser.add_argument("count", type=int, help="how many to create")
        parser.add_argument("--prefix", default="user", help="name prefix")
        parser.add_argument("--dry-run", action="store_true",
                            help="don't actually create")

    def handle(self, *args, **options):
        count = options["count"]
        prefix = options["prefix"]
        if options["dry_run"]:
            self.stdout.write(f"Would create {count} users")
            return
        for i in range(1, count + 1):
            self.stdout.write(f"Created {prefix}{i}")

# Run it: python manage.py seed_users 5 --prefix guest
# add_arguments(self, parser) defines the command line. Django builds an
# argparse parser, you register positional and --optional arguments on it,
# and the parsed values arrive in handle() as the **options dict.

import argparse

class Command:
    def add_arguments(self, parser):
        # positional argument
        parser.add_argument("count", type=int, help="how many to create")
        # optional flag with a default
        parser.add_argument("--prefix", default="user", help="name prefix")
        # boolean flag
        parser.add_argument("--dry-run", action="store_true",
                            help="don't actually create")

    def parse(self, argv):
        parser = argparse.ArgumentParser(prog="seed_users")
        self.add_arguments(parser)
        return vars(parser.parse_args(argv))   # -> the **options dict

cmd = Command()
print(cmd.parse(["3"]))
print(cmd.parse(["5", "--prefix", "guest"]))
print(cmd.parse(["2", "--dry-run"]))

# EXPECTED:
# {'count': 3, 'prefix': 'user', 'dry_run': False}
# {'count': 5, 'prefix': 'guest', 'dry_run': False}
# {'count': 2, 'prefix': 'user', 'dry_run': True}

# ✅ Expected output:
# {'count': 3, 'prefix': 'user', 'dry_run': False}
# {'count': 5, 'prefix': 'guest', 'dry_run': False}
# {'count': 2, 'prefix': 'user', 'dry_run': True}

3 Output, Styling, and Scheduling with cron

Inside handle you write output with self.stdout.write() rather than print, and you colourise it with self.style — SUCCESS (green), WARNING (yellow), and ERROR (red). Once the command works, you schedule it with cron because it is just a normal program.

# inside handle()
self.stdout.write(self.style.SUCCESS("Import finished"))
self.stdout.write(self.style.WARNING("3 rows skipped"))
self.stderr.write(self.style.ERROR("Could not reach API"))
# crontab -e  — run the cleanup every night at 02:30
30 2 * * * cd /srv/myproject && /srv/venv/bin/python manage.py prune_sessions --before 1700000000

# minute hour day-of-month month day-of-week  command
#   30    2       *          *        *        ...
# Inside handle() you write output with self.stdout.write(), wrapping success
# messages in self.style.SUCCESS so they print in green. Here we model stdout
# and the style helpers, then run a full command end to end.

import argparse

class Style:
    def SUCCESS(self, msg): return "[SUCCESS] " + msg
    def WARNING(self, msg): return "[WARNING] " + msg
    def ERROR(self, msg):   return "[ERROR] " + msg

class StdOut:
    def write(self, msg): print(msg)

class Command:
    def __init__(self):
        self.stdout = StdOut()
        self.style = Style()

    def add_arguments(self, parser):
        parser.add_argument("count", type=int)
        parser.add_argument("--prefix", default="user")

    def handle(self, **options):
        count = options["count"]
        prefix = options["prefix"]
        for i in range(1, count + 1):
            self.stdout.write(f"Created {prefix}{i}")
        self.stdout.write(self.style.SUCCESS(f"Done: {count} created"))

    def run(self, argv):
        parser = argparse.ArgumentParser(prog="seed_users")
        self.add_arguments(parser)
        options = vars(parser.parse_args(argv))
        self.handle(**options)

# python manage.py seed_users 3 --prefix guest
Command().run(["3", "--prefix", "guest"])

# EXPECTED:
# Created guest1
# Created guest2
# Created guest3
# [SUCCESS] Done: 3 created

# ✅ Expected output:
# Created guest1
# Created guest2
# Created guest3
# [SUCCESS] Done: 3 created

🎯 Your Turn: Type a Command Argument

An argument's type converts the raw text from the command line. Fill in the blank so "5" is parsed into the integer 5.

# 🎯 YOUR TURN: register a command-line argument named "count" that must be
# an integer. Replace ___ with the type so "5" is parsed as the number 5.

import argparse

parser = argparse.ArgumentParser(prog="seed")
parser.add_argument("count", type=___)

options = vars(parser.parse_args(["5"]))
print(options)
print("count + 1 =", options["count"] + 1)

# EXPECTED OUTPUT:
# {'count': 5}
# count + 1 = 6

Common Errors (And How to Fix Them)

❌ Unknown command (manage.py can't find it)

The folder layout is wrong, or a missing __init__.py hides the package.

✅ Fix: put the file in app/management/commands/ with __init__.py in both folders.

You declared --dry-run but tried to read options["dry-run"] with a hyphen.

✅ Fix: argparse converts hyphens to underscores — read options["dry_run"].

❌ Output is hard to test or doesn't show in logs

You used print() inside handle instead of the command's stream.

✅ Fix: use self.stdout.write(...) (and self.style) so output is captured properly.

📋 Quick Reference

PieceRole
class Command(BaseCommand)Defines the command
handle(self, *args, **options)Where the work happens
add_arguments(self, parser)Declare command-line inputs
self.stdout.write(...)Print output the right way
self.style.SUCCESS(...)Colourise a message green

🏆 Mini Challenge: A prune_sessions Command

Build a command that deletes expired sessions. It takes a required --before cutoff and a --dry-run flag that reports what would be deleted without changing anything.

# 🏆 Mini Challenge: a "prune_sessions" command that deletes expired rows.
# It takes a --before timestamp and a --dry-run flag. On a dry run it reports
# what WOULD be deleted without changing anything.

import argparse

class Command:
    def add_arguments(self, parser):
        parser.add_argument("--before", type=int, required=True)
        parser.add_argument("--dry-run", action="store_true")

    def handle(self, sessions, **options):
        cutoff = options["before"]
        dry_run = options["dry_run"]
        expired = [s for s in sessions if s["expires"] < cutoff]
        if dry_run:
            return f"DRY RUN: would delete {len(expired)} session(s)"
        return f"Deleted {len(expired)} session(s)"

    def run(self, argv, sessions):
        parser = argparse.ArgumentParser(prog="prune_sessions")
        self.add_arguments(parser)
        options = vars(parser.parse_args(argv))
        return self.handle(sessions, **options)

sessions = [
    {"id": 1, "expires": 100},
    {"id": 2, "expires": 250},
    {"id": 3, "expires": 90},
]

cmd = Command()
print(cmd.run(["--before", "200", "--dry-run"], sessions))
print(cmd.run(["--before", "200"], sessions))

# EXPECTED:
# DRY RUN: would delete 2 session(s)
# Deleted 2 session(s)

❓ Frequently Asked Questions

Lesson complete — you can automate your project!

You can subclass BaseCommand, implement handle, declare inputs with add_arguments, read them from options, print styled output with self.stdout and self.style, and schedule the command with cron.

Practice quiz

What class must a custom management command subclass?

  • BaseCommand
  • Command
  • AppConfig
  • ManagementCommand

Answer: BaseCommand. A command class subclasses BaseCommand from django.core.management.base.

Which method is the entry point Django calls to run a command?

  • run()
  • execute()
  • handle(self, *args, **options)
  • main()

Answer: handle(self, *args, **options). handle() is where the command's work happens.

Where must a custom command file live to be discovered?

  • app/commands/
  • app/management/commands/
  • app/scripts/
  • project root

Answer: app/management/commands/. Commands go in app/management/commands/, both with __init__.py.

What becomes the name you type after manage.py?

  • The class name
  • The help string
  • The file name
  • The app label

Answer: The file name. The command's file name becomes the command name.

Where do you declare command-line arguments?

  • add_arguments(self, parser)
  • handle()
  • __init__()
  • get_arguments()

Answer: add_arguments(self, parser). add_arguments registers positional and optional arguments on the parser.

A flag declared as --dry-run is read from options under which key?

  • dry-run
  • dryRun
  • dry_run
  • --dry-run

Answer: dry_run. argparse converts hyphens to underscores, so it is options['dry_run'].

How should a command print output instead of using print()?

  • sys.stdout.flush()
  • self.stdout.write()
  • logging.info()
  • return the text

Answer: self.stdout.write(). self.stdout.write() integrates with Django's output handling.

Which style helper colourises a success message green?

  • self.success()
  • self.style.OK()
  • self.style.GREEN()
  • self.style.SUCCESS()

Answer: self.style.SUCCESS(). self.style.SUCCESS() prints the message in green.

How do you typically schedule a management command to run nightly?

  • With the operating system's scheduler, like cron
  • With a Django setting
  • Inside settings.py
  • It runs automatically

Answer: With the operating system's scheduler, like cron. A command is a normal program, so cron schedules it.

What does the parser come from in add_arguments?

  • A custom Django parser
  • A standard argparse parser Django provides
  • A JSON config
  • The handle method

Answer: A standard argparse parser Django provides. Django hands you a standard argparse parser to register arguments on.

Continue this course

Frequently asked questions

Where do I put a custom management command?

Inside an app, create a management/commands/ directory and place a Python file there named after the command, for example myapp/management/commands/seed_users.py. Both the management and commands folders need an __init__.py so Python treats them as packages. Django automatically discovers any file in that location whose module defines a Command class, and the filename becomes the command name you run with manage.py.

What is the difference between handle and add_arguments?

add_arguments(self, parser) is where you declare the command's command-line interface by registering positional and optional arguments on the argparse parser Django gives you. handle(self, *args, **options) is where the actual work happens; the values that were parsed from the command line arrive in the options dictionary. add_arguments runs first to define the inputs, then handle runs with those inputs available.

How do I print output the right way from a command?

Use self.stdout.write() instead of the print function, because it integrates with Django's output handling and testing. Wrap messages in the style helpers to colourise them: self.stdout.write(self.style.SUCCESS('Done')) prints in green, while self.style.WARNING and self.style.ERROR give yellow and red. For genuine errors you can also raise CommandError, which Django reports cleanly and uses to set a non-zero exit code.

How do I run a management command on a schedule?

A management command is just a program you invoke with python manage.py <name>, so you schedule it with your operating system's scheduler. On Linux that is cron: you add a crontab line that runs the command at the desired interval, making sure to use the project's virtual environment and absolute paths. This is how teams run nightly cleanups, periodic imports, and report generation without any extra framework.