Typer Subcommands: How to Organize Multi-Feature CLI Apps
As your CLI tool grows, a single command becomes insufficient. Subcommands let you group related operations under a main command—think git commit, git push, git log, all subcommands of git. Typer makes organizing these hierarchies intuitive and keeps your code modular.
What are Subcommands?
A subcommand is a nested command that belongs to a parent command. The syntax is myapp command subcommand --options. For example, docker container run has container as a command group and run as a subcommand. Subcommands let you scale CLIs from simple tools to complex suites.
Creating Your First Subcommand
Here's a basic structure with a parent app and multiple commands:
import typer
app = typer.Typer()
@app.command()
def create(name: str):
"""Create a new project."""
typer.echo(f"Creating project: {name}")
@app.command()
def delete(name: str):
"""Delete an existing project."""
typer.echo(f"Deleting project: {name}")
if __name__ == "__main__":
app()
Run with: python myapp.py create MyProject or python myapp.py delete MyProject. The @app.command() decorator registers each function as a top-level subcommand automatically.
Organizing Commands into Groups
For larger tools, group related commands together:
import typer
app = typer.Typer()
db_app = typer.Typer()
config_app = typer.Typer()
# Database commands
@db_app.command()
def migrate():
"""Run database migrations."""
typer.echo("Running migrations...")
@db_app.command()
def seed():
"""Seed database with test data."""
typer.echo("Seeding database...")
# Config commands
@config_app.command()
def get(key: str):
"""Get a config value."""
typer.echo(f"Getting config: {key}")
@config_app.command()
def set(key: str, value: str):
"""Set a config value."""
typer.echo(f"Setting {key}={value}")
# Register command groups
app.add_typer(db_app, name="db")
app.add_typer(config_app, name="config")
if __name__ == "__main__":
app()
Run with: python myapp.py db migrate or python myapp.py config set api_key xyz123. Each group is a separate Typer instance, keeping code organized and testable.
Shared State Across Commands
Sometimes commands need to share configuration or state. Use a class-based approach:
import typer
from typing import Optional
class AppState:
def __init__(self, debug: bool = False):
self.debug = debug
self.config = {}
app = typer.Typer()
state = AppState()
@app.callback(invoke_without_command=True)
def main(debug: bool = typer.Option(False)):
"""Main CLI with optional debug mode."""
state.debug = debug
if debug:
typer.echo("[DEBUG MODE ON]")
@app.command()
def process(file: str):
"""Process a file."""
if state.debug:
typer.echo(f"[DEBUG] Processing {file}")
typer.echo(f"Processed: {file}")
@app.command()
def status():
"""Show app status."""
if state.debug:
typer.echo(f"[DEBUG] App state: {state.config}")
typer.echo("App running normally")
if __name__ == "__main__":
app()
The @app.callback() decorator runs before any command, making it ideal for global setup. invoke_without_command=True allows the callback to run even when a subcommand is invoked.
Nested Command Groups
You can nest command groups for very large applications:
import typer
app = typer.Typer()
# User management
user_app = typer.Typer()
@user_app.command()
def create(username: str, email: str):
"""Create a new user."""
typer.echo(f"User {username} ({email}) created")
@user_app.command()
def delete(username: str):
"""Delete a user."""
typer.echo(f"User {username} deleted")
# Admin subgroup
admin_app = typer.Typer()
@admin_app.command()
def assign_role(username: str, role: str):
"""Assign a role to a user."""
typer.echo(f"Assigned {role} to {username}")
# Nest admin under user
user_app.add_typer(admin_app, name="admin")
# Add user group to main app
app.add_typer(user_app, name="user")
if __name__ == "__main__":
app()
Run with: python myapp.py user create john [email protected] or python myapp.py user admin assign_role john admin. This creates a hierarchy: user > admin > assign_role.
Command Aliases and Multiple Names
Let users invoke commands by multiple names:
import typer
app = typer.Typer()
@app.command(name="create", rich_help_panel="Project Management")
def create_project(name: str):
"""Create a new project."""
typer.echo(f"Created: {name}")
@app.command(name="new")
def new_project(name: str):
"""Alias for create."""
create_project(name)
if __name__ == "__main__":
app()
Users can run python myapp.py create MyProject or python myapp.py new MyProject with the same result.
Comparison Table: Command Organization Patterns
| Pattern | Use Case | Complexity |
|---|---|---|
| Single commands | Small utility tools | Low |
| Top-level subcommands | Tools with 3-5 operations | Low-Medium |
| Command groups | Organized tools with 10+ commands | Medium |
| Nested groups | Large suites (docker, kubectl style) | High |
| Shared state + callbacks | Multi-command with configuration | High |
Key Takeaways
- Subcommands are registered with
@app.command()or grouped via separateTyperinstances withapp.add_typer(). - Use
@app.callback()to run setup logic before any subcommand executes. - Group related commands into separate
Typer()objects to keep code organized and testable. - Nested command groups create hierarchies; each group can have its own subcommands.
rich_help_panellets you organize help text into themed sections for better readability.
Frequently Asked Questions
How do I make one subcommand the default if no subcommand is given?
Set invoke_without_command=True in @app.callback(). If a user runs just myapp with no subcommand, the callback runs.
Can I have both global options and per-command options?
Yes. Global options go in @app.callback() and are available to all commands. Command-specific options go in the @app.command() function signature.
Is there a limit to how deep I can nest command groups?
Technically no, but usability suffers. Most tools stop at 2-3 levels deep. git > worktree > list is three levels; much deeper becomes hard to remember.
How do I pass data from a callback to a command?
Use a shared object (like AppState in the example above) or rely on closure variables. The callback runs before the command, so any state it sets is available to commands.
Can subcommands be optional or have defaults?
Not in the Click/Typer model. A subcommand is either invoked or not. If you want optional behavior, use the callback + flags approach.