Writing Django management commands can involve a ton of boilerplate code. But Revsys uses two libraries that cut our management command code in half while making it more readable and powerful: django-click
and django-typer
.
With django-click, adding arguments is as simple as a decorator, and with django-typer, you get rich terminal output that can help you understand your data. My management commands are faster to develop, easier to test, and more pleasant to use.
I'll show you real examples of both libraries in action and share when we use each one for client projects. By the end, you'll understand why we reach for these libraries most of the time.
Why we write custom Django management commands at Revsys
Before we dive into the code, let's talk about management commands. At Revsys, I write management commands frequently. They're one of my most-used tools for handling the kinds of tasks that come up in real client projects.
Here are some typical use cases I encounter:
- Data operations: I import CSV files from clients, export data for reports, or set up realistic test data for local development. Management commands make these repeatable and scriptable.
- API integrations: Sometimes I need to manually trigger a webhook that's failed, or run a one-off API call that would normally happen automatically. Having a management command ready means I can handle these situations quickly without writing throwaway scripts.
- Complex data transformations: When you've got thousands of records that need to go through a multi-step process (maybe updating related models, generating computed fields, or migrating data formats) a management command gives you a controlled environment to do that work.
- Development and debugging: I'll write commands to reset data to a specific state, run quick reports to answer questions about data ("show me all users missing email addresses"), or test specific parts of the application in isolation.
The key insight is that management commands give you a way to write self-contained, reusable pieces of code that operate within your Django application's context. They've got access to your models, settings, and all your business logic, but they're separate from your request/response cycle.
Instead of writing one-off scripts that you'll lose track of, or cramming logic into the Django shell, management commands give you a proper home for all these ad-hoc but important tasks. The right tools can make writing them much more pleasant.
The standard Django management command experience (and why it's frustrating)
To understand why these libraries are helpful, let's look at what Django gives us out of the box. I'll use a movie data loader as an example:
# management/commands/load_movies_django.py
import json
from pathlib import Path
from django.conf import settings
from django.core.management.base import BaseCommand
from movies.utils import clear_movie_data, load_movies_from_data
class Command(BaseCommand):
help = "Load movies data from JSON file into the database."
def add_arguments(self, parser):
parser.add_argument(
"--file", default="data/movies.json", help="Path to movies JSON file"
)
parser.add_argument(
"--clear", action="store_true", help="Clear existing data before loading"
)
parser.add_argument(
"count", type=int, nargs="?", help="Number of movies to load (optional)"
)
def handle(self, *args, **options):
if options["clear"]:
self.stdout.write(self.style.WARNING("Clearing existing movie data..."))
clear_movie_data()
self.stdout.write(self.style.SUCCESS("Existing data cleared."))
file_path = Path(settings.BASE_DIR) / options["file"]
if not file_path.exists():
self.stdout.write(self.style.ERROR(f"Error: File {file_path} not found"))
return
self.stdout.write(self.style.NOTICE(f"Loading movies from {file_path}..."))
with open(file_path) as f:
movies_data = json.load(f)
count = options["count"]
if count is not None:
movies_data = movies_data[:count]
self.stdout.write(self.style.NOTICE(f"Loading first {count} movies..."))
total_created_movies, total_created_genres, total_created_cast = (
load_movies_from_data(movies_data)
)
self.stdout.write(self.style.SUCCESS("\nLoading complete!"))
self.stdout.write(self.style.SUCCESS(f"Created {total_created_movies} movies"))
self.stdout.write(self.style.SUCCESS(f"Created {total_created_genres} genres"))
self.stdout.write(
self.style.SUCCESS(f"Created {total_created_cast} cast members")
)
This works fine, but there's a lot of boilerplate and its output is pretty simple.
- Class inheritance and method structure: You need to inherit from
BaseCommand
and implement specific methods before you can do anything useful. - Verbose argument setup: The
add_arguments()
method requires you to manually configure an argument parser. You have to specify types, defaults, and help text separately from where you'll use them. - Manual option parsing: Throughout your
handle()
method, you're constantly accessingoptions["key"]
instead of having clean function parameters. - Basic styling: Django's built-in styling with
self.style.SUCCESS()
works but feels verbose and limited.
The business logic (loading movies from JSON) is buried under all this infrastructure code.
The output looks like this:

There are cleaner approaches.
Django-click: simpler command definition
django-click is a Django wrapper around the Click library. It transforms management commands from classes with methods into simple functions with decorators.
Installation and setup
pip install django-click
No configuration needed.
What I like about django-click
For me, django-click's appeal comes from a few key concepts:
- Function-based commands: Instead of classes, you write a simple function decorated with
@click.command()
. - Decorator-driven arguments: Use
@click.option()
and@click.argument()
decorators to define your command's interface right above the function, so it's easy to see what your arguments and options are at a glance. - Direct parameter access: Your function receives arguments as regular Python parameters, not through an options dictionary. It's a more intuitive way of handling arguments.
- Built-in colorful output:
click.secho()
provides easy styled terminal output. - Automatic help generation: Click generates help text from your decorators and docstrings.
Personally, I really like the pattern of having the arguments or options be listed in the function definition and as decorators. It's very clear, it gives me an at-a-glance view of what my options are, and I immediately have those variables available to use like any other argument. The whole command feels more minimal and simpler than a standard Django management command, so the commands come together really quickly.
Real-world example: Movie import with django-click
Now let me show you that same command using django-click:
# management/commands/load_movies_click.py
import djclick as click
from django.conf import settings
from movies.utils import clear_movie_data, load_movies_from_data
@click.command()
@click.option("--file", default="data/movies.json", help="Path to movies JSON file")
@click.option("--clear", is_flag=True, help="Clear existing data before loading")
@click.argument("count", type=int, required=False)
def command(file, clear, count):
"""Load movies data from JSON file into the database."""
if clear:
click.secho("Clearing existing movie data...", fg="yellow")
clear_movie_data()
click.secho("Existing data cleared.", fg="green")
file_path = Path(settings.BASE_DIR) / file
if not file_path.exists():
click.secho(f"Error: File {file_path} not found", fg="red", err=True)
return
click.secho(f"Loading movies from {file_path}...", fg="blue")
with open(file_path) as f:
movies_data = json.load(f)
if count is not None:
movies_data = movies_data[:count]
click.secho(f"Loading first {count} movies...", fg="cyan")
total_created_movies, total_created_genres, total_created_cast = (
load_movies_from_data(movies_data)
)
click.secho("\nLoading complete!", fg="green", bold=True)
click.secho(f"Created {total_created_movies} movies", fg="green")
click.secho(f"Created {total_created_genres} genres", fg="green")
click.secho(f"Created {total_created_cast} cast members", fg="green")
The actual work being done is identical, but the command structure is much cleaner.
The command definition is right there at the top with the decorators. The function signature tells you exactly what parameters you're working with, with no more options["key"]
lookups. Here is the output (similar to regular Django):

A few other improvements:
- Arguments vs Options: Notice that
count
is a positional argument whilefile
andclear
are optional flags. Click handles the difference automatically. - Colorful output:
click.secho()
withfg="green"
is much cleaner thanself.style.SUCCESS()
. - Boolean flags: The
is_flag=True
parameter makes--clear
work as a simple boolean flag.
Another cool django-click feature: the lookup utility
Django-click also includes a useful lookup
utility for working with Django models. You can use it to accept model instances as command arguments:
import djclick as click
from myapp.models import User
@click.command()
@click.argument('user', type=click.ModelInstance(User))
def command(user):
"""Do something with a user."""
click.echo(f"Processing user: {user.username}")
The click.ModelInstance(User)
automatically handles lookups by primary key by default. You can also specify custom lookup fields:
# Lookup by username field
@click.argument('user', type=click.ModelInstance(User, lookup='username'))
This returns the actual User instance to your function, making it easy to work with Django models in your commands.
Django-typer: When you need beautiful output that helps you think
django-typer takes a different approach. Built on Typer, it uses Python type annotations to define command interfaces and includes the Rich library for beautiful terminal output.
Installation and setup
pip install django-typer
This brings in Typer. If you install with pip install django-typer[rich]
, you will also get the Rich library and its capabilities, which we will go into below.
Key differences from django-click
- Type annotation driven: Instead of decorators, you use Python type annotations with
typer.Option()
andtyper.Argument()
to define your interface. - Class-based but simpler: You can still inherit from a base class (
TyperCommand
), but the interface is much cleaner than standard Django commands. There is also a decorator available if you prefer that style. - Rich integration: Beautiful progress bars, tables, panels, and colorful output are easy to implement if you include Rich in your installation.
- Better error handling: Typer provides more sophisticated error handling and validation.
Recently, I needed to dig into some messy client data and answer questions like "Do all the records that are missing a FK to this other model also share these other characteristics?" My goal was to figure out if I needed to write some custom code to "fix" some records I suspected were broken, or if there was a valid reason the records were in the state they were in.
With django-typer, I wrote a command that answered my questions and helped me identify patterns in my data. The structured output made it easier to spot patterns I might have missed in a plain text dump. Django-typer is great when you need output that helps you analyze data, not just dump it to the terminal.
Real-world example: Movie data import command
Converting our movie import command to django-typer shows how type annotations replace decorators:
from django_typer.management import Typer
app = Typer(help="Load movies data from JSON file into the database.")
@app.command()
def main(
count: int | None = typer.Argument(None, help="Number of movies to load"),
file: str = typer.Option("data/movies.json", help="Path to movies JSON file"),
clear: bool = typer.Option(False, help="Clear existing data before loading"),
):
if clear:
typer.secho("Clearing existing movie data...", fg=typer.colors.YELLOW)
clear_movie_data()
typer.secho("Existing data cleared.", fg=typer.colors.GREEN)
file_path = Path(settings.BASE_DIR) / file
if not file_path.exists():
typer.secho(f"Error: File {file_path} not found", fg=typer.colors.RED, err=True)
raise typer.Exit(1)
typer.secho(f"Loading movies from {file_path}...", fg=typer.colors.BLUE)
with open(file_path) as f:
movies_data = json.load(f)
total_created_movies, total_created_genres, total_created_cast = (
load_movies_from_data(movies_data)
)
typer.secho("\nLoading complete!", fg=typer.colors.GREEN, bold=True)
Pretty similar to the django-click version, just with type annotations instead of decorators. (I trimmed some logic for brevity, but you get the idea.)
You could strip the django-typer function definition down even further, like so:
def main(count: int = None, file: str = "data/movies.json", clear: bool = False):
Then, the function definition would very closely resemble any standard Python function. But then you lose the help text for your arguments and options, and you lose access to some of the extra validation that Typer can do on your behalf.
Making the movie import command output sparkle
If you need structured, visual output from a management command, django-typer can be helpful. If you install it with pip install django-typer[rich]
and include the Rich integration, you can create very well-formatted output in your CLI.
from rich.console import Console
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.table import Table
app = Typer(help="Load movies data from JSON file into the database.")
@app.command()
def main(
# same function definition as before
):
"""Load movies data from JSON file into the database."""
console = Console()
# Display a pretty welcome banner
console.print(Panel.fit("🎬 Movie Database Loader", style="bold blue"))
with open(file_path) as f:
movies_data = json.load(f)
if count is not None:
movies_data = movies_data[:count]
console.print(f"🔢 Loading first [bold yellow]{count}[/bold yellow] movies...")
# Add a progress bar
with Progress(console=console) as progress:
task = progress.add_task("🎭 Processing movies...", total=len(movies_data))
total_created_movies, total_created_genres, total_created_cast = (
load_movies_from_data(movies_data)
)
progress.update(task, completed=len(movies_data))
# Add a table to summarize the output
table = Table(title="📊 Loading Summary", style="green")
table.add_column("Category", style="cyan", no_wrap=True)
table.add_column("Count", style="magenta", justify="right")
table.add_column("Icon", justify="center")
table.add_row("Movies", str(total_created_movies), "🎬")
table.add_row("Genres", str(total_created_genres), "🎭")
table.add_row("Cast Members", str(total_created_cast), "👥")
console.print()
console.print(table)
Adding these elements gives you output like this:

Adding these elements from the Rich library shows how many elements you can add to your CLI output. The Rich elements I used were:
- Rich panels: The welcome banner get displayed in a Panel with pretty borders
- Progress indicators: We get a progress indicator via Rich's Progress class.
- Beautiful tables: We used a Table to display our output in an organized and easy-to-read way.
- Rich markup: We use familiar-sounding arguments like
style
andjustify
to style our output.
When to use django-click
If you prefer decorator syntax over type annotations, you want minimal dependencies in your project, you're already familiar with Click from other projects, you need the lookup
utility for Django model integration, or you're writing simple commands that don't need fancy output, then django-click might be the library for you.
When to use django-typer
If you love type annotations and want automatic validation, you need beautiful output with minimal effort, you're building complex command suites with subcommands, you don't mind the extra dependencies, or you want Rich integration for tables, progress bars, and panels, then give django-typer a try.
Integration with Just
This is off the topic of django-typer and django-click, but I wanted to mention it: I often use Just to handle situations where I need to run multiple management commands in a specific way. When I set up commands for all three approaches:
# Load movies data into database
load-fresh-movie-data:
just load-genres --clear 1000
just load-people --clear 1000
just load-movies --clear 1000
load-genres *args:
docker compose exec web python manage.py load_genres {{args}}
load-people *args:
docker compose exec web python manage.py load_people {{args}}
load-movies *args:
docker compose exec web python manage.py load_movies {{args}}
This pattern lets you create shortcuts for your management commands, and link them together.
My honest take
I use django-click for most of the management commands I need to write. It's clean, fast to write, and gets out of my way. But when I need to build something that helps me understand complex data or provides structured feedback during long-running operations, django-typer is the better choice.
The next time you need to write a management command, try one of these libraries and let me know what you think!