Comparison¶
How footman stacks up against the Python task runners I measured it against,
on 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.14, 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 and it will be fixed.
Why not make, just, or task?¶
Different species: those are recipe runners — you write shell in a file and
they run it, and they are very good at it. footman runs typed Python
functions, which is what buys checked arguments, --help written from
signatures, and completion without wrappers. If your tasks are shell
one-liners, keep the recipe runner; the moment one grows arguments, logic,
or parallelism, it turns into a Python function that wants a runner.
Migrating has the tool-by-tool guides.
Milliseconds are the least interesting thing here
This is the one page on the site that carries numbers, deliberately. Startup timings move with your interpreter, your machine and your project, and a runner that wins one of these tables by 20 ms is not why you would pick it. Two structural facts survive any re-measurement, and they are the ones worth reading for: completion never imports your project, and independent work runs in parallel by default. The rest of the site talks about what footman does; the digits live here.
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.
Its tools library in particular is the direct inspiration for footman's: it
is where I got the idea that a task runner should ship typed wrappers for the
tools you actually call. Footman's take separates the two halves duty fuses:
wrapping a command-line utility is generic, and the type hints are a layer
generated on top. So the code reads the same whether or not a stub has heard
of your tool: toolroom.terraform("plan") runs exactly like
toolroom.ruff.check(), and a stub only decides whether your editor can
help. The handles live in toolroom,
footman's companion package, and detect the footman host on every call.
Completion latency¶
Cold-process wall time per <TAB>: median of 40 fresh processes after three
discarded warmups, ± half the p10–p90 spread. 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 | 20±1 ms | 0 ms | no — cached manifest |
| poe | 45±1 ms | 0 ms | no — reads TOML |
| invoke | 336±20 ms | 263 ms | yes |
| duty | 349±9 ms | 289 ms | yes |
duty and invoke reload your whole project on every Tab, because 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). It pays the same import cost as everyone else, just on the
execution path: fm --list is ~307 ms, right there with the pack. Completion is
the one moment that has to feel instant, so that is the moment to spend on. poe
is quick here too, for the simple reason that its tasks are TOML strings with no
Python to load, which is also the rest of this page.
The same table on CPython 3.11 — the oldest version footman supports — reads 20 ms, 39 ms, 324 ms, 316 ms: within noise of the 3.14 run above. Which is the useful thing to know about all of these numbers. The interpreter you run is not what separates these tools; whether a keystroke imports your project is.
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 (a fair 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,
so 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) | 592 ms |
| poe | parallel (parallel task) |
617 ms |
| typer | sequential (no orchestration) | 2080 ms |
| duty | sequential (pre-duties) | 2131 ms |
| invoke | sequential (pre-tasks) | 2210 ms |
The floors are 0.5 s parallel and 2.0 s sequential, so everyone's overhead
is a rounding error: the gap is architecture, not dispatch speed. duty and
invoke run prerequisites one at a time 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
sequential 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, 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 |
+0.2 ms |
import typer |
+40 ms |
typer's import really is heavier: it ships its own parser plus rich and
shellingham. (Reproduce with
uv run --group comparison python scripts/bench_import.py.)
That import cost is not the whole launch, though, and on launch typer wins: running a no-op task costs 41±2 ms through a typer app against footman's 55±5 ms (37 ms against 53 ms on 3.11 — the gap travels). footman spends its milliseconds elsewhere — the cascade walk, the grammar, eager validation — and if bare launch speed is what you are shopping for, typer is the lighter tool. The difference reverses when a typer app does completion, because that re-runs the app and pays the typer import and your project import on every Tab, where footman answers from cache. Different jobs, and the honest summary is that footman's speed story is about what it doesn't do on a keystroke, not about dispatch.
Feature matrix¶
The list is footman's own feature set, so the left column is green by construction, so the real content is in the other columns.
| 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 | ✅ | ❌ | ✅ | ✅ | seq task |
Parallel-by-default DAG (pre/post) |
✅ | ❌ | sequential | sequential | ✅ |
run() capture / replay-on-failure |
✅ | ❌ | ✅ | partial | ❌ |
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.
† invoke declares no dependencies either. It gets there by vendoring:
invoke/vendor/ ships fluidity, lexicon and PyYAML inside the wheel.
footman carries no third-party code at all — the difference is where the
dependency lives, not whether you install one.