TL;DR

Django allows custom management commands to extend the core manage.py interface, allowing easy integration with the application when building backend processes, automation scripts, and scheduled jobs (while providing access into the Django application environment, data model and functions via the same structures used to build the website).

Where to create and/or find the code

Django discovers management commands through a specific directory layout in your apps:

your_app/
    management/
        __init__.py
        commands/
            __init__.py
            send_notifications.py
            update_reports.py

Every command file defines a Command class that extends BaseCommand:

from django.core.management.base import BaseCommand

class Command(BaseCommand):
    help = 'Send notifications to users for new reports'

    def handle(self, *args, **options):
        # Command logic goes here
        self.stdout.write('Task completed')

Run the command with python manage.py send_notifications. The filename becomes the command name.

Adding Command-Line Arguments

The add_arguments() method configures your command’s interface using Python’s argparse:

def add_arguments(self, parser):
    parser.add_argument(
        '--dry-run',
        action='store_true',
        help='Run without making changes',
    )

    parser.add_argument(
        '--force',
        action='store_true',
        help='Run all active searches regardless of schedule',
    )

Access arguments through the options dictionary in handle():

def handle(self, *args, **options):
    dry_run = options['dry_run']
    force = options['force']

    if dry_run:
        self.stdout.write(self.style.WARNING('Running in DRY-RUN mode'))

Proper Output and Styling

Always use self.stdout and self.stderr instead of print statements. This ensures output is captured correctly during testing and allows output redirection:

# Good practices
self.stdout.write(f'Processing user: {user.username}')
self.stdout.write(self.style.SUCCESS('Task completed successfully'))
self.stdout.write(self.style.WARNING('Running in dry-run mode'))
self.stderr.write(self.style.ERROR('Failed to process item'))

The style attribute provides colored output that works across terminals: SUCCESS, WARNING, ERROR, NOTICE, and SQL_KEYWORD.

Example: Scheduled Notifications

Here’s a simplified version of a notification command that sends emails about new reports:

from django.core.management.base import BaseCommand
from django.core.mail import send_mail
from django.contrib.auth import get_user_model
from django.conf import settings

from tracker.models import Report, NotificationLog

User = get_user_model()

class Command(BaseCommand):
    help = 'Send notifications to users for new reports'

    def add_arguments(self, parser):
        parser.add_argument(
            '--dry-run',
            action='store_true',
            help='Show what would be sent without sending',
        )

    def handle(self, *args, **options):
        dry_run = options['dry_run']
        total_sent = 0

        for user in User.objects.filter(is_active=True):
            # Find new reports since last notification
            last_notification = NotificationLog.objects.filter(
                user=user
            ).order_by('-sent_at').first()

            if last_notification:
                new_reports = Report.objects.filter(
                    user=user,
                    created_at__gt=last_notification.sent_at
                )
            else:
                new_reports = Report.objects.filter(user=user)[:10]

            if not new_reports.exists():
                continue

            self.stdout.write(f'Found {new_reports.count()} reports for {user.username}')

            if dry_run:
                self.stdout.write(self.style.WARNING(
                    f'  [DRY-RUN] Would send email to {user.email}'
                ))
            else:
                send_mail(
                    subject='New Reports Available',
                    message=f'You have {new_reports.count()} new reports.',
                    from_email=settings.DEFAULT_FROM_EMAIL,
                    recipient_list=[user.email],
                )
                total_sent += 1
                self.stdout.write(self.style.SUCCESS(
                    f'  Sent notification to {user.email}'
                ))

        self.stdout.write(self.style.SUCCESS(
            f'Completed: {total_sent} notifications sent'
        ))

Schedule this with cron:

# Run every hour
0 * * * * cd /path/to/project && python manage.py send_notifications

Example: Async Report Generation

Commands can use async functionality for tasks like web scraping or API calls:

import anyio
from django.core.management.base import BaseCommand
from django.utils import timezone
from datetime import timedelta

from tracker.models import ProductSearch, Report

class Command(BaseCommand):
    help = 'Run due product searches and generate reports'

    def add_arguments(self, parser):
        parser.add_argument(
            '--force',
            action='store_true',
            help='Run all searches regardless of schedule',
        )

    async def fetch_product_data(self, search):
        """Async method to fetch product information."""
        # Use async HTTP client, browser automation, etc.
        pass

    def handle(self, **options):
        force = options.get('force', False)
        command_start = timezone.now()

        searches = ProductSearch.objects.filter(is_active=True)

        for search in searches:
            # Check if search is due
            if not force and search.last_searched:
                time_since = command_start - search.last_searched
                if time_since < timedelta(hours=search.frequency_hours):
                    continue

            self.stdout.write(f'Processing: {search.product_name}')

            try:
                # Run async task
                result = anyio.run(self.fetch_product_data, search)

                # Create report
                Report.objects.create(
                    user=search.user,
                    content=result,
                    report_type='price_check'
                )

                # Update last run time
                search.last_searched = command_start
                search.save()

                self.stdout.write(self.style.SUCCESS('  Report created'))

            except Exception as e:
                self.stderr.write(self.style.ERROR(f'  Failed: {e}'))

Error Handling

Raise CommandError for expected problems that should terminate the command:

from django.core.management.base import BaseCommand, CommandError

class Command(BaseCommand):
    def handle(self, *args, **options):
        if not settings.EMAIL_CONFIGURED:
            raise CommandError('Email not configured. Set EMAIL_HOST in settings.')

This displays a clean error message without a full traceback. For unexpected errors, let them propagate naturally so you see the full context during development.

Commands and Transactions

By default, Django wraps each command execution in a database transaction (when using a transactional database). For long-running commands that should commit incrementally, use the @transaction.atomic decorator on specific sections:

from django.db import transaction

class Command(BaseCommand):
    def handle(self, *args, **options):
        for batch in get_batches():
            with transaction.atomic():
                # This batch commits independently
                process_batch(batch)

Testing Commands

Test commands using Django’s call_command() helper:

from io import StringIO
from django.core.management import call_command
from django.test import TestCase

class CommandTests(TestCase):
    def test_dry_run_mode(self):
        out = StringIO()
        call_command('send_notifications', '--dry-run', stdout=out)
        self.assertIn('DRY-RUN', out.getvalue())

        # Verify no emails were sent
        self.assertEqual(len(mail.outbox), 0)

When to Use Management Commands

Management commands excel for:

  • Scheduled jobs: Daily reports, cleanup tasks, data synchronization
  • Batch processing: Import/export operations, bulk updates
  • System integration: Connecting Django with external services or APIs
  • Development tools: Database seeding, cache warming, test data generation
  • Administrative tasks: User management, permission updates, maintenance operations

They provide Django’s full environment including ORM access, settings, and logging while staying outside the web request cycle.

Summary

Custom management commands extend Django’s manage.py interface to handle backend processes and automation. They provide argument parsing, proper error handling, styled output, and full access to your Django application’s models and configuration.

The commands in this post demonstrate patterns for notification systems and scheduled report generation, but the same approach applies to any task that needs Django’s ORM and settings without the request-response cycle.

Reference: Django Custom Management Commands