| in blog | REVSYS Blog |
|---|---|
| original entry | Better Django management commands with django-click and django-typer |
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.
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:
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.
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.
BaseCommand and implement specific methods before you can do anything useful.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.handle() method, you're constantly accessing options["key"] instead of having clean function parameters.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 is a Django wrapper around the Click library. It transforms management commands from classes with methods into simple functions with decorators.
No configuration needed.
For me, django-click's appeal comes from a few key concepts:
@click.command().@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.click.secho() provides easy styled terminal output.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.
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:
count is a positional argument while file and clear are optional flags. Click handles the difference automatically.click.secho() with fg="green" is much cleaner than self.style.SUCCESS().is_flag=True parameter makes --clear work as a simple boolean flag.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 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.
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.
typer.Option() and typer.Argument() to define your interface.TyperCommand), but the interface is much cleaner than standard Django commands. There is also a decorator available if you prefer that style.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.
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.
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:
style and justify to style our output.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.
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.
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.
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!