Intermediate
Every project accumulates a collection of repetitive commands — running tests, building documentation, deploying to staging, linting code, cleaning build artifacts. Most teams handle this with a Makefile or a pile of shell scripts that nobody fully understands six months later. Makefiles are powerful but cryptic. Shell scripts lack documentation and type safety. There is a better way.
invoke is a Python task runner that lets you define automation tasks as regular Python functions decorated with @task. Your team runs inv test instead of remembering a long pytest command. You run inv deploy --env=staging instead of sourcing an environment file and running three scripts in sequence. Tasks are documented, composable, and written in the language your whole team already knows.
This article covers how to install and configure invoke, how to define tasks with arguments and dependencies, how to run shell commands from tasks, how to group tasks into namespaces, and how to build a complete project automation workflow. By the end you will have a tasks.py file that replaces your project’s Makefile entirely.
Task Automation with invoke: Quick Example
Here is a minimal tasks.py file with two tasks you can run immediately:
# tasks.py
from invoke import task
@task
def hello(c):
"""Say hello and show the current directory."""
print("Hello from invoke!")
c.run("pwd")
@task
def clean(c):
"""Remove Python bytecode and cache files."""
c.run("find . -type f -name '*.pyc' -delete")
c.run("find . -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true")
print("Cleaned up bytecode files.")
Run with:
inv hello
# Hello from invoke!
# /home/user/myproject
inv clean
# Cleaned up bytecode files.
inv --list
# Available tasks:
# clean Remove Python bytecode and cache files.
# hello Say hello and show the current directory.
Each task is a Python function decorated with @task. The first argument c is the Context object that provides the c.run() method for running shell commands. The function’s docstring becomes the task description shown in inv --list. The sections below go deeper into arguments, dependencies, namespaces, and real-world patterns.
What Is invoke and Why Use It?
invoke was created by the same developer who built the Fabric deployment library. It provides a clean framework for defining, documenting, and running project tasks without leaving Python. Unlike Makefiles, invoke tasks are regular Python functions with full access to Python’s standard library, third-party packages, and IDE support including type checking and autocompletion.
| Feature | Makefile | Shell Script | invoke |
|---|---|---|---|
| Language | Make syntax | Bash | Python |
| Documentation | Comments only | Comments only | Docstrings + –list |
| Arguments | Variables | $1, $2, … | Named params with types |
| Dependencies | Target prereqs | Manual calls | pre/post decorators |
| Cross-platform | Limited | No (bash-specific) | Yes (Python) |
| IDE support | None | Limited | Full |
Install invoke with pip, then create a tasks.py file in your project root. The inv command automatically discovers this file when run from that directory or any subdirectory.
# terminal -- run this to install
# pip install invoke
import invoke
print(invoke.__version__)
2.2.0

Tasks with Arguments and Defaults
Tasks accept keyword arguments that users pass on the command line. Default values make arguments optional. Boolean flags become --flag / --no-flag switches automatically. This gives you a proper CLI for free.
# tasks.py
from invoke import task
@task
def test(c, module="", verbose=False, coverage=False):
"""Run the test suite.
Args:
module: Specific module to test (default: all)
verbose: Show verbose output
coverage: Generate a coverage report
"""
cmd = "pytest"
if verbose:
cmd += " -v"
if coverage:
cmd += " --cov=src --cov-report=html"
if module:
cmd += f" tests/test_{module}.py"
else:
cmd += " tests/"
c.run(cmd)
Usage:
inv test
inv test --verbose
inv test --module=utils --verbose
inv test --coverage
Each Python function argument becomes a CLI flag. Strings become --arg=value options. Booleans become --flag / --no-flag pairs. Integers accept numeric values. invoke infers the type from the default value, so verbose=False creates a boolean toggle and module="" creates a string option.
Task Dependencies
Tasks often need to run in sequence — clean before build, build before deploy. invoke handles this with the pre and post arguments to the @task decorator. Pre-tasks run before the main task; post-tasks run after it completes successfully.
# tasks.py
from invoke import task
@task
def clean(c):
"""Remove build artifacts."""
c.run("rm -rf dist/ build/ *.egg-info")
print("Build directory cleaned.")
@task
def lint(c):
"""Run linting checks."""
c.run("ruff check src/")
print("Linting passed.")
@task(pre=[clean, lint])
def build(c):
"""Build the package (runs clean and lint first)."""
c.run("python -m build")
print("Build complete.")
@task(pre=[build])
def publish(c, test=False):
"""Publish to PyPI (or TestPyPI with --test)."""
if test:
c.run("twine upload --repository testpypi dist/*")
else:
c.run("twine upload dist/*")
Output when running inv publish –test:
Build directory cleaned.
Linting passed.
Build complete.
Uploading distributions to https://test.pypi.org/legacy/
The dependency chain runs automatically: inv publish triggers build, which first runs clean and lint. You do not have to remember the order — it is encoded in the task definitions. Tasks are deduplicated if multiple paths would run the same task twice.

Using the Context and c.run()
The Context object c is the core of every invoke task. Its c.run() method executes shell commands and gives you control over output, errors, and environment. Understanding c.run() options is essential for writing robust tasks.
# tasks.py
from invoke import task
@task
def deploy(c, env="staging"):
"""Deploy the application to the specified environment."""
print(f"Deploying to {env}...")
# hide=True suppresses output (returns it instead)
result = c.run("git rev-parse HEAD", hide=True)
commit = result.stdout.strip()
print(f"Deploying commit: {commit[:8]}")
# warn=True prevents exception on non-zero exit code
result = c.run("systemctl is-active myapp", warn=True, hide=True)
if result.ok:
print("Service is running -- will restart after deploy")
else:
print("Service is stopped -- will start after deploy")
# Run with a specific working directory
with c.cd("/var/www/myapp"):
c.run("git pull origin main")
c.run("pip install -r requirements.txt -q")
c.run("python manage.py migrate --noinput")
c.run("systemctl restart myapp")
print(f"Deploy to {env} complete!")
Key c.run() options:
| Option | Default | Effect |
|---|---|---|
hide=True | False | Suppress output; returns it in result.stdout |
warn=True | False | Return result instead of raising on non-zero exit |
echo=True | False | Print the command before running it |
env={} | None | Merge extra env vars into the subprocess |
pty=True | False | Allocate a pseudo-terminal (for interactive programs) |
Organizing Tasks with Namespaces
As projects grow, a flat list of tasks becomes hard to navigate. invoke supports namespaces to group related tasks. You define tasks in separate modules and combine them into a namespace tree.
# tasks/db.py
from invoke import task
@task
def migrate(c):
"""Run database migrations."""
c.run("python manage.py migrate")
@task
def seed(c):
"""Seed the database with sample data."""
c.run("python manage.py loaddata fixtures/sample.json")
@task
def backup(c):
"""Backup the database to a timestamped file."""
import datetime
stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
c.run(f"pg_dump mydb > backups/db_{stamp}.sql")
print(f"Backup created: db_{stamp}.sql")
# tasks.py (root)
from invoke import Collection
from tasks import db
namespace = Collection()
namespace.add_collection(Collection.from_module(db), name="db")
Running namespaced tasks:
inv --list
# db.migrate Run database migrations.
# db.seed Seed the database with sample data.
# db.backup Backup the database to a timestamped file.
inv db.migrate
inv db.backup
Namespaces keep your task list organized as it grows. You can nest namespaces arbitrarily deep. A common pattern is to have a top-level tasks.py that imports and organizes sub-modules for different concerns: db, deploy, docs, test.

Real-Life Example: Full Project Automation Workflow
Here is a complete tasks.py for a typical Python project covering testing, linting, docs, and deployment:
# tasks.py -- full project automation
from invoke import task, Collection
import os
@task
def clean(c):
"""Remove all build artifacts and cache files."""
patterns = [
"dist/", "build/", "*.egg-info",
".pytest_cache/", ".ruff_cache/",
"htmlcov/", ".coverage",
]
for pattern in patterns:
c.run(f"rm -rf {pattern}", warn=True)
c.run("find . -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true")
print("Clean complete.")
@task
def lint(c):
"""Run ruff linter and formatter check."""
c.run("ruff check src/ tests/")
c.run("ruff format --check src/ tests/")
print("Lint passed.")
@task
def test(c, verbose=False, coverage=False, module=""):
"""Run the test suite."""
cmd = "pytest"
if verbose:
cmd += " -v"
if coverage:
cmd += " --cov=src --cov-report=term-missing --cov-report=html"
if module:
cmd += f" tests/test_{module}.py"
else:
cmd += " tests/"
c.run(cmd)
@task(pre=[lint, test])
def check(c):
"""Run all quality checks (lint + tests)."""
print("All checks passed!")
@task(pre=[clean, check])
def build(c):
"""Build distribution packages."""
c.run("python -m build")
result = c.run("ls dist/", hide=True)
print("Built:", result.stdout.strip())
@task(pre=[build])
def release(c, test=False):
"""Release to PyPI (use --test for TestPyPI)."""
repo = "testpypi" if test else "pypi"
c.run(f"twine upload --repository {repo} dist/*")
print(f"Released to {repo}!")
# Set up namespace
ns = Collection(clean, lint, test, check, build, release)
Developer workflow:
inv check # lint + test before committing
inv build # clean, check, then build
inv release --test # test release to TestPyPI
inv release # production release
This single file replaces a Makefile, a set of shell scripts, and institutional knowledge about command flags. New team members run inv --list and immediately understand what automation is available. The dependency chain ensures nothing is skipped by accident.
Frequently Asked Questions
How is invoke different from Make?
Makefiles are primarily designed for build systems with file dependency tracking (only rebuild what changed). invoke is a general-purpose task runner without file dependency semantics. invoke tasks are Python functions with full language support, IDE integration, and proper argument parsing. For Python projects, invoke is almost always more readable and maintainable than an equivalent Makefile.
How do I share configuration across tasks?
Use invoke’s configuration system. Create a invoke.yaml file in your project root with shared settings, or pass a config object to your collection. Inside tasks, access config via c.config.my_setting. You can also layer configs: project defaults overridden by user settings overridden by environment variables. This is much cleaner than a pile of global constants at the top of your tasks file.
How do I run interactive commands (like a REPL) from invoke?
Use c.run("command", pty=True) to allocate a pseudo-terminal. This is required for programs that detect whether they are running in a terminal and change behavior accordingly — such as python, psql, or vim. Without pty=True, these commands may behave unexpectedly or refuse to start in interactive mode.
Can I use invoke in CI pipelines?
Yes. invoke works identically in CI because it is just Python. Add invoke to your requirements-dev.txt and call inv test in your CI config the same way developers do locally. This eliminates the common problem of CI running different commands than local development. Your pipeline becomes a single source of truth: inv lint && inv test && inv build.
Can invoke run tasks in parallel?
invoke does not have built-in parallel task execution in the standard way. However, you can use Python’s concurrent.futures or threading inside a task to parallelize work. For parallel lint and test runs, a common pattern is to use c.run() with asynchronous=True to start background subprocesses and then wait for them. For most project automation, sequential execution is fast enough that parallelism is unnecessary.
Conclusion
The invoke library turns repetitive project commands into discoverable, documented, composable Python tasks. You learned how to define tasks with @task, add CLI arguments and defaults, chain tasks with pre/post dependencies, use the Context for shell commands, organize tasks into namespaces, and build a complete project automation workflow.
The next step is to replace your project’s Makefile or script folder with a tasks.py. Start with your three most-used commands and convert them first. Once your team experiences inv --list, they rarely want to go back to memorizing command flags. The official invoke documentation at pyinvoke.org covers configuration files, collection inheritance, and the executor API for advanced use cases.