Skip to main content

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

PatternUse CaseComplexity
Single commandsSmall utility toolsLow
Top-level subcommandsTools with 3-5 operationsLow-Medium
Command groupsOrganized tools with 10+ commandsMedium
Nested groupsLarge suites (docker, kubectl style)High
Shared state + callbacksMulti-command with configurationHigh

Key Takeaways

  • Subcommands are registered with @app.command() or grouped via separate Typer instances with app.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_panel lets 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.

Further Reading