Taskfile: A Modern Task Runner That Replaces Make Without the Pain
All the convenience of make, none of the tab-versus-space trauma

Contents
I have a long and complicated relationship with make. It is everywhere, it is reliable, and it has been quietly running the world’s builds for nearly fifty years. It also punishes you with significant whitespace, a recipe language that is really shell-inside-make-inside-shell with three layers of escaping, and the eternal indignity of Makefile:12: *** missing separator. Stop. because you typed spaces where a literal tab was required. For most of what people actually use Make for these days — not building C, but running a handful of project commands — Taskfile is the replacement I wish I’d switched to years ago.
What Taskfile is
Taskfile is a task runner written in Go, configured with a single Taskfile.yml. It ships as one static binary with no runtime dependencies, which means no Python, no Node, nothing to install but task itself. You define named tasks, give them commands, declare dependencies between them, and run task <name>. If that sounds exactly like Make, that’s deliberate — but it’s Make with YAML instead of tabs, real variable handling, built-in file-checksum change detection, and cross-platform behaviour that doesn’t fall apart the moment a Windows developer joins the team.
Installation is a one-liner:
| |
A real Taskfile
Here is a Taskfile.yml close to what I drop into a typical service repo. Note the desc fields — they’re not decoration, they power task --list:
| |
Run task with no arguments and you get the self-documenting list. Run task docker and it runs test first, which runs build first, exactly like Make’s prerequisites — but the dependency graph is declared in plain deps: rather than buried in target syntax.
Migrating an existing Makefile
If you already have a Makefile, the good news is that the translation is almost mechanical, because the mental model is the same: named targets, prerequisites, commands. The differences are all improvements you’ll be glad of. A Make target like this:
| |
becomes the build/test tasks you saw above, with three concrete upgrades. The $(wildcard ...) glob that Make evaluates once at parse time becomes an explicit sources: list that Task uses for change detection. The tab-indented recipe body — the source of the infamous missing separator error — becomes an ordinary YAML cmds: list with no whitespace superstition. And the prerequisite test: build becomes an explicit deps: [build], which reads the same but no longer collides with Make’s habit of treating every prerequisite as a file that must exist on disk.
The one thing to watch during migration is shell semantics. In a Makefile, each line of a recipe is a separate shell unless you join them, and Make has its own .ONESHELL special target to change that. In Task, each entry in cmds: is a separate shell, full stop. So a Make recipe that does cd build && cmake .. on one line ports cleanly, but one that relies on shell state persisting across recipe lines needs the lines joined with && or a task-level dir:. Once you internalise “one cmds entry, one shell”, the rest of the migration is find-and-replace.
I usually port the five or six targets people actually run — build, test, lint, deploy, clean — leave the genuinely build-system-shaped targets in Make if any exist, and delete the rest. Most Makefiles I inherit turn out to be 90% command launcher and 10% actual build, and only that 10% has any business staying in Make.
The features that actually matter
Two things make me reach for Taskfile over Make beyond cosmetics.
First, smart change detection. In the build task above, sources and generates tell Task to skip the build entirely if no .go file changed since the last run. Make does this too, but only via mtime comparison, which breaks the moment a file’s timestamp lies (checkouts, container layers, restored caches). Task can use content checksums instead — add method: checksum and it hashes the inputs rather than trusting timestamps. That alone has saved me from a class of “why did this rebuild / why didn’t this rebuild” mysteries.
Second, variables that behave. The GIT_SHA variable above runs a shell command and captures its output once, cleanly. Doing the equivalent in Make involves $(shell ...), deferred-versus-immediate assignment (= versus :=), and the constant question of how many dollar signs you need this time. Task’s templating is Go’s text/template, which is a known quantity with documented functions, not folklore.
You can also include other Taskfiles, which is how I keep a shared base of common tasks and pull it into each repo:
| |
Then task common:lint runs the included task. It’s a genuinely sane module system, which is more than Make’s include ever managed. I keep a single Taskfile.common.yml with the tasks every repo needs — lint, fmt, ci, a clean — and pull it in everywhere. Fix a bug in the shared lint task once and every project inherits the fix on next checkout. Try doing that cleanly with a pile of copy-pasted Makefiles.
Two more features earn their keep once you’ve lived with them for a while. The first is preconditions, which fail a task early with a readable message if the environment is wrong — no docker binary, no .env file, wrong branch — instead of vomiting a shell error twelve commands deep. The second is watch mode: task test --watch re-runs the task whenever a source file changes, using the same sources: globs that drive change detection. It turns a test task into a live feedback loop with no extra tooling:
| |
Where Make still wins
I won’t pretend this is a clean sweep. If you are doing real incremental compilation of a large C or C++ project with complex pattern rules and a tuned dependency graph, Make is purpose-built for that and Task is not trying to replace it. Make is also guaranteed present on essentially every Unix box ever made, whereas Task is one more binary to install in CI — trivial, but it is a step. And there is a deep bench of Make knowledge in the wild; far fewer people can debug your Taskfile at 2am.
The honest framing: Make is a build system that people abuse as a task runner. Task is a task runner that doesn’t pretend to be a build system. For the “make deploy / make test / make migrate” use case — which is what most modern Makefiles actually are — Task wins on readability and predictability.
It’s also not the only contender in this space. just is the other modern task runner people reach for, and the two make genuinely different trade-offs — just keeps a Make-like recipe syntax while Task goes all-in on YAML and its templating engine. I compared them properly in my rundown of just and Task as Make alternatives; if you find YAML-with-Go-templates grating, just may suit you better, and neither choice is wrong.
Troubleshooting the sharp edges
Task is pleasant, but a few things trip up newcomers who arrive from Make with Make-shaped instincts.
“My task always reruns even though nothing changed.” You’ve declared cmds but not sources/generates, so Task has nothing to compare and runs every time — which is correct behaviour, just not what you expected. Add both, and for anything where mtimes are unreliable (CI checkouts, restored caches, container layers), add method: checksum so it hashes contents instead of trusting timestamps.
Every command runs in its own shell. Each entry in cmds: is a separate shell invocation, so cd foo on one line does not affect the next — a classic surprise for people who write multi-line shell in a single Make recipe. Either chain with && on one line, or set a task-level dir: to run the whole task in a given directory.
Variables aren’t expanding. Task templating uses Go’s {{.VAR}} double-brace syntax, not shell $VAR. Mixing them silently does the wrong thing: {{.IMAGE}} is a Task variable resolved before the shell runs, $IMAGE is an environment variable resolved by the shell. Know which layer you’re at. When you genuinely need a literal $ — say in a container that reads its own env — you’re back in shell territory, and it behaves like shell.
Dynamic vars running at the wrong time. A sh:-backed variable like the GIT_SHA above runs when the Taskfile is parsed, not when the task runs. If you need a value computed at task time — inside a specific directory, say — compute it in the cmds instead. This is the Task equivalent of Make’s immediate-versus-deferred assignment, and it’s the one gotcha that survives the migration.
If your workflow leans on containers, Task pairs neatly with a rootless runtime — I drive most of my build and test tasks against Podman rather than Docker for the reasons in running containers without Docker, and Task doesn’t care which one you point it at.
Is it worth it?
If your Makefile is really a command launcher and you’ve been quietly resenting it, yes, switch. The migration is mechanical, the YAML is readable by people who’ve never touched Make, and task --list turns your project’s commands into self-documenting help that new contributors actually use. The cost is a binary in your toolchain and explaining to one greybeard why you abandoned a fifty-year-old standard.
If you’re compiling C with intricate incremental rules, stay on Make — that’s its home turf. For everything else I now reach for a Taskfile by default, and I haven’t typed “missing separator” since.
One last practical note: the switch is reversible and low-stakes, which is a good reason to just try it on one repo. Drop a Taskfile.yml alongside the existing Makefile, port the handful of commands you actually run, and live with both for a week. If it doesn’t earn its place, delete the YAML and you’ve lost nothing but an afternoon. In my experience the task --list output alone — the moment a new contributor discovers your project’s commands without reading a single line of source — is usually enough to make the Makefile the thing that quietly gets deleted instead.




