Just and Task: Modern Alternatives to Make That Don't Make You Cry
Two task runners that fix the parts of Make everyone quietly hates

Contents
Almost every project I touch accumulates a pile of little commands: build the thing, run the tests, regenerate the assets, deploy to staging. For decades the reflex answer was a Makefile, and for decades a Makefile has been quietly torturing everyone who isn’t compiling C. Tab-versus-space errors that print nothing useful. Recipes that fail silently because each line runs in its own shell. Variable expansion that fights you because Make has its own $ and the shell has another. Make is a brilliant build tool wearing a task-runner costume, and the costume doesn’t fit. This article is about two tools — just and Task — that fit much better.
What’s actually wrong with Make
Let me be fair: Make is genuinely good at what it was built for, which is rebuilding files when their dependencies change, using timestamps to skip work. If you have a real dependency graph of source files producing artefacts, Make earns its keep.
The trouble is that most modern “Makefiles” don’t do that at all. They’re a menu of phony targets — make build, make lint, make deploy — that run regardless of timestamps. For that job, Make’s machinery becomes a liability. Every recipe line is a fresh shell, so cd somewhere on one line is forgotten by the next. The leading-tab requirement bites newcomers weekly. And $ collisions mean you’re forever doubling dollar signs to escape Make’s expansion. You end up writing a build system to run three shell commands.
The one that catches people hardest is the fresh-shell-per-line rule, because it fails silently. Consider this innocent-looking Make recipe:
| |
You’d expect apply.sh to run inside deploy/staging. It doesn’t. Make runs cd deploy/staging in one shell, that shell exits, and then ./apply.sh runs in the original directory — or fails with “no such file”. No error about the cd, no warning, just wrong behaviour. The fix in Make is to chain everything with && and line-continuations, which is exactly the kind of noise you came here to escape. Both tools below make a recipe body a single shell script by default, which is the behaviour every newcomer already assumed they had.
just: a command runner, and only that
just is refreshingly honest about its scope. It is not a build system. It does not track file timestamps or dependencies between artefacts. It is a command runner: you define named recipes in a justfile, and just <name> runs them. That narrower remit is exactly why it’s pleasant.
A justfile looks deceptively like a Makefile, but the sharp edges are gone. Whole recipes run in one shell by default, variables are sane, and you get real parameters.
| |
Note what’s happening. deploy takes a parameter and depends on build, so just deploy staging builds first. The cd on one line is still in effect on the next, because the recipe body runs as a single script. Variables interpolate with {{ }}, leaving the shell’s $ entirely alone. And just --list gives you a self-documenting menu for free. None of this requires a tab, and a stray space won’t detonate.
just also does string functions, conditional logic, and per-recipe shebangs, so a recipe can be a Python or Node script inline if you fancy. The shebang trick is the one I use most — it lets me stop pretending awkward logic belongs in shell:
| |
It’s a single Rust binary with no runtime dependencies, which makes it trivial to drop onto a CI runner: one curl of the release tarball, or cargo install just, brew install just, apt install just — pick your poison.
Task: YAML, cross-platform, and dependency-aware
Task (the binary is task, the file is Taskfile.yml) takes a different tack. It’s written in Go, ships as one static binary, and — crucially — it does understand inputs and outputs, so it can skip work when nothing changed, much like Make, but without Make’s syntax.
| |
Because build declares sources and generates, running task build twice in a row will report task: Task "build" is up to date and do nothing the second time — proper incremental behaviour. deps runs prerequisites, and by default independent dependencies run in parallel. task --list prints every desc, and {{.CLI_ARGS}} forwards extra arguments. Being YAML, it’s instantly familiar to anyone who lives in CI configuration, and it behaves the same on Windows as on Linux, which Make absolutely does not.
Why discoverability is the real win
The feature I undersold for years is --list. Both tools turn the file itself into documentation: every recipe gets an optional description, and just --list or task --list prints a clean menu of what the project can do. New contributors stop asking “how do I run the tests here?” because the answer is one command they’ll guess on the first try. With a Makefile, that menu doesn’t exist unless someone hand-maintains a help target full of echo lines that immediately drift out of date.
| |
That self-documenting menu is, in my experience, the single biggest reason a team actually adopts the new tool rather than quietly reverting to the Makefile. It removes a class of “how does this repo work” friction that no amount of README writing ever quite kills, because the README lies eventually and --list reads the file you’re about to run.
Choosing between them
The decision is mostly about whether you want change detection.
Reach for just when you just want a tidy, discoverable menu of commands and you don’t care about skipping unchanged work. Its syntax is the least surprising of the three, parameters are first-class, and the mental model is “named shell scripts with arguments.” For most web apps, infrastructure repos and personal projects, this is all you need, and it’s the one I install first.
Reach for Task when you genuinely benefit from sources/generates skipping, want parallel dependencies out of the box, or need solid Windows support without bending over backwards. The YAML is a little more verbose than a justfile, but the up-to-date checking pays for itself on slower builds.
Stick with Make only when you have a real file-dependency graph — codegen, compilation pipelines, asset transforms — where its timestamp engine is the whole point, and your team already knows its quirks.
If you want a deeper look at Task specifically — its checksum-based change detection, included taskfiles for monorepos, and where the YAML starts to bite — I’ve written that up separately in Taskfile: a modern task runner that replaces Make without the pain. This post is the comparison; that one is the field guide.
Gotchas and troubleshooting
Neither tool is magic, and the migration has a few sharp corners worth flagging before you hit them at 6pm on a Friday.
just doesn’t track files, and people forget. If you migrate a Makefile that genuinely relied on timestamps — “only rebuild app if a .go file changed” — just will happily rebuild every single time, because it has no concept of staleness. That’s not a bug, it’s the design, but it surprises people who assumed they were getting a drop-in Make replacement. If you need skip-when-unchanged, that’s Task’s sources/generates, not just.
Task’s Go template syntax trips up shell muscle memory. Variables are {{.NAME}} with a leading dot, and forgetting the dot gives you an empty string rather than an error. If a command mysteriously runs with a blank argument, check your dots first.
Recipe arguments differ, and it matters in CI. just deploy staging passes staging as a named parameter; Task forwards extra arguments via {{.CLI_ARGS}} after a -- separator (task deploy -- staging). Mixing the two mental models is the single most common migration error I see.
Watch where the binary comes from. Both tools are single static binaries, which is lovely, but it means you are now responsible for keeping them current across machines and runners. I treat the task-runner binary as just another dependency to keep patched — the same hygiene mindset I apply to everything else in Renovate Bot: automated dependency updates that don’t break everything. Pin a version in CI rather than always pulling latest, or a release that changes default behaviour will eventually ruin a deploy.
set dotenv-load (just) and dotenv (Task) are not on by default. If your recipes expect environment variables from a .env file and they’re coming through empty, you almost certainly forgot to opt in.
A quick migration recipe that works for most repos: copy each phony Make target into a just recipe verbatim, delete every && and trailing backslash (the body is one shell now), remove the doubled $$ back down to single $, and replace $(VAR) with {{var}}. Run just --list and you’ve got a self-documenting menu where the Makefile used to be. Budget an afternoon, not a sprint.
Is it worth switching?
If your Makefile is secretly a list of phony targets — and most are — then yes, almost unreservedly. The migration is an afternoon: most recipes copy across nearly verbatim, minus the escaping headaches. The payoff is a self-documenting --list, recipes that share a shell, parameters that don’t fight you, and no more cryptic missing separator errors ruining someone’s first day. Both tools are single static binaries, so adding them to CI is one download.
I’d point individuals and small teams at just for its sheer ergonomics, and reach for Task when incremental builds or Windows parity actually matter. And you don’t have to choose globally: it’s entirely reasonable to keep a real Makefile for the compilation graph in one repo and use just as the friendly command menu in another, picking per-project rather than declaring a one-true-tool. The mistake is using Make for everything out of habit, including the dozen repos where it’s only ever running phony targets.
Either way, you can finally stop apologising to new contributors about the tabs — and stop losing an hour to a cd that silently didn’t take effect. That’s an afternoon of migration against years of small daily friction, which is about the best return on effort a piece of tooling ever offers.




