Skip to content

Comparison

How footman stacks up against the Python task runners I measured it against — the same seven-task surface (lint, format, typecheck, test, check, dist build, dist clean) written five ways. The runnable head-to-head lives in the repo's comparison/ directory; reproduce the numbers with uv run --group comparison python comparison/bench_compare.py. Switching from one of these runners? The practical guides live on Migrating.

Measured on duty 1.9.0, invoke 3.0.3, poethepoet 0.48.0, typer 0.27.0, CPython 3.13, M-series Mac.

Verified, not vibes

Every number and checkmark here was checked against the tools themselves, on the same seven tasks. If any of it is wrong or has become unfair to a tool, open an issue — it will be fixed.

First, some love for duty

Before any table makes footman look clever: I've been running my projects on duty for nearly two years, and it's been a pleasure the whole way. footman exists because of duty — the ctx.run capture model, the lazy tool wrappers, the decorator ergonomics are all ideas I'm happily standing on. This is a "here's what I wanted to tweak," not a takedown. And duty still wins outright in one place: its tools standard library is far more extensive and detailed than footman's handful — dozens of tools, carefully typed. footman has some catching up to do there.

Completion latency

Cold-process wall time per <TAB>, mean of 15 fresh processes. The Δ import column is the one that matters: completion time with a 0.25 s project-import cost minus completion time without it. Re-import your tasks on every keystroke and you see the whole ~0.25 s; answer from a cache and you see roughly nothing.

runner completion (per TAB) Δ import re-imports every TAB?
footman 23 ms ~0 ms no — cached manifest
poe 45 ms ~0 ms no — reads TOML
duty 346 ms 286 ms yes
invoke 360 ms 289 ms yes

duty and invoke reload your whole project on every TAB — their completion scripts call the tool, which imports your tasks before it can answer. footman reads a cached JSON manifest instead, so the hot path imports nothing (a dynamic completer or the first build in a fresh directory spawns a bounded subprocess), and it lands about 15× faster. It pays the same import cost as everyone else, just on the execution path: fm --list is ~313 ms, right there with the pack. Completion is the one moment that has to feel instant, so that's the moment I optimised. poe is quick here too, for the honest reason that its tasks are TOML strings with no Python to load — which is also the rest of this page.

The same check, composed five ways

Completion is the moment that has to feel instant; check is the command you actually run fifty times a day. So: four check steps, each an identical in-process 0.5 s sleep (the honest stand-in for an I/O-bound tool run — a real lint step spawns a subprocess and waits, which parallelises exactly like a sleep), composed the way each tool wants you to. Fairness cuts both ways — a tool that supports parallelism gets to use it. Reproduce with uv run --group comparison python comparison/bench_check.py.

runner composition wall (mean)
footman parallel (pre-deps, default) 563 ms
poe parallel (parallel task) 625 ms
typer serial (no orchestration) 2092 ms
duty serial (pre-duties) 2120 ms
invoke serial (pre-tasks) 2146 ms

The floors are 0.5 s parallel and 2.0 s serial, so everyone's overhead is a rounding error — the 4× gap is architecture, not dispatch speed. duty and invoke run prerequisites serially and have no parallel switch to flip; the same four steps simply cost the sum instead of the max. poe genuinely ticks this box (a dedicated parallel task type — credit where due); the difference is spelling. In poe you declare a parallel composite per case; in footman pre=[fmt, lint, typecheck, test] is parallel by default and goes serial only when you ask (-s). And typer hands you nothing here — four calls in a row, unless you hand-roll a thread pool, at which point you've written the scheduler yourself.

Is "just write a typer app" too heavy?

Genuine question, because typer is lovely and a completely reasonable choice — if you're building a user-facing CLI rather than a task runner, honestly, reach for typer. It's also footman's closest relative here: typed signatures, real flags, Enum/Literal validation, nested apps. The only thing I measured was startup, because typer has a reputation for being heavy:

import cost over a bare interpreter
import footman +4 ms
import typer +24 ms

typer's import really is ~6× heavier — it ships its own parser plus rich and shellingham. (Reproduce with uv run --group comparison python scripts/bench_import.py.) On a single launch you'd never notice (footman ~38 ms, typer ~40 ms; footman just spends its milliseconds on parsing instead of importing). The difference only shows up when a typer app does completion, because that re-runs the app — paying the typer import and your project import on every TAB, where footman is answering from cache. Not a knock on typer; just a different job.

Feature matrix

The list is footman's own feature set, so the left column is green by construction — the honest content is in the other columns, and in the one ❌ footman concedes: duty's tools library.

capability footman typer duty invoke poe
Typed Python-function tasks
No ctx/c boilerplate param
Real --flags
Literal/Enum → validated choices
Union / one-or-many / dict[K,V] params partial
Native nested groups manual
Zero-boilerplate discovery (module = group)
Separator-free chaining reserved-word reserved-word seq task
Parallel-by-default DAG (pre/post) serial serial
run() capture / replay-on-failure partial
Extensive typed tools standard library
Monorepo tasks.py cascade
Custom-branded CLI as a library
Completion without re-importing ✅*
Zero runtime dependencies

* poe skips the re-import only because its tasks aren't Python functions.