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

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.
1 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.
2 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.
# justfile
set dotenv-load # load a .env file automatically
registry := "ghcr.io/smarc"
# list recipes when run with no argument
default:
@just --list
build tag="latest":
docker build -t {{registry}}/app:{{tag}} .
# this whole recipe is ONE shell invocation
deploy env: build
cd deploy/{{env}}
./apply.sh
echo "Deployed to {{env}}"
test:
go test ./...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. It’s a single Rust binary with no runtime dependencies, which makes it trivial to drop onto a CI runner.
3 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.
# Taskfile.yml
version: '3'
vars:
BINARY: app
tasks:
build:
desc: Build the binary
sources:
- "**/*.go"
generates:
- "{{.BINARY}}"
cmds:
- go build -o {{.BINARY}} ./cmd/app
test:
desc: Run tests
deps: [build]
cmds:
- go test ./...
deploy:
desc: Deploy to an environment
cmds:
- ./deploy.sh {{.CLI_ARGS}}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.
4 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.
5 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. Either way, you can finally stop apologising to new contributors about the tabs.




