just: A Command Runner That Isn’t Pretending to Be a Build System

Every team accumulates a folder of half-remembered shell one-liners: the command that rebuilds the container, the one that runs the right subset of tests, the migration incantation nobody can recall. just turns that folder into a single, discoverable, self-documenting file — without pretending to be a build system.

Most teams reach for a Makefile to hold these commands, then spend the next year fighting it. Make is a build system: it exists to decide whether a target is out of date relative to its inputs and skip work that is already done. When all you want is to give a name to a shell command, that machinery is pure friction — phony targets, tab-versus-space rituals, arguments that have to be smuggled in through environment variables. just is the tool that does only the task-running half, and does it well. It is a single binary written in Rust, it reads a justfile, and it has no opinion whatsoever about whether your outputs are up to date. That restraint is the whole point.

What just is, and what it deliberately is not

just is a command runner. You write named recipes, each recipe is a list of shell commands, and you invoke them with just recipename. That is the entire mental model. It borrows Make’s syntax closely enough to feel familiar — recipes, dependencies, variable interpolation — but it drops the file-timestamp dependency graph that makes Make confusing for anything other than compiling C.

Free · 4 minutes

Is your engineering team shipping safely, or quietly accumulating risk?

Fourteen questions on how work gets from idea to production — cadence, testing, rollback, and the key-person risk in your delivery. Banded finding on screen, full sheet by email.

What you gain in exchange is ergonomics Make never had: real named arguments with defaults, .env loading built in, per-recipe shells, recipes written in other languages, and a proper --list that turns your justfile into a menu. What you give up is incremental builds. If your problem is genuinely “only recompile the files that changed,” just is the wrong tool and will not pretend otherwise. For the scripts a team runs by hand — setup, test, lint, build, deploy, migrate — it is a much better fit.

A justfile you can actually read

Here is a justfile for a Python service that covers the features worth knowing on day one: arguments with defaults, prior dependencies, dotenv loading, a chosen shell, a variadic recipe, and a private helper.

# justfile — your project's commands. Run `just` (no args) to list them.

set dotenv-load                              # load .env into recipe env, if present
set shell := ["bash", "-euo", "pipefail", "-c"]

# Read a value from .env or the shell, with a fallback
env := env_var_or_default("APP_ENV", "dev")

# With no target, print the recipe list rather than run the first recipe
default:
    @just --list

# Install dependencies
setup:
    uv sync

# Run tests; narrow with a path, e.g. `just test tests/unit`
test path="tests":
    uv run pytest {{path}}

# Format then lint (both prior dependencies run before this recipe's body)
check: fmt lint

fmt:
    uv run ruff format .

lint:
    uv run ruff check .

# Default value computed at parse time by a backtick command
build tag=`git rev-parse --short HEAD`:
    docker build -t myapp:{{tag}} .

# Migrate first, then run — `migrate` is a prior dependency
run: migrate
    uv run uvicorn app.main:app --reload

migrate:
    uv run alembic upgrade head

# Variadic: forward zero or more extra args straight through
logs *args:
    docker compose logs {{args}}

# Private helper, hidden from `just --list`
[private]
_confirm:
    @read -p "Proceed for {{env}}? [y/N] " ok && [ "$ok" = "y" ]

# Destructive recipe gated behind the confirmation helper
reset-db: _confirm migrate
    @echo "database reset for {{env}}"

A few things are doing real work here. set dotenv-load loads a .env file from the working directory into every recipe’s environment if one is present — the same secrets your application reads, without a wrapper script. set shell := ["bash", "-euo", "pipefail", "-c"] makes every recipe run under a strict bash so a failing command in a pipeline actually fails the recipe instead of being silently swallowed. Recipes without a shebang run line by line, each line in a fresh shell, which is exactly the behaviour you want for short command lists.

Arguments and dependencies, the way Make should have done them

The test path="tests" recipe takes a real positional argument with a default. Run just test and it runs the whole suite; run just test tests/unit and it narrows. No TARGET=… environment-variable smuggling, no reading the Makefile to work out which variable to set. The logs *args recipe is variadic: just logs -f api forwards every extra argument straight through to docker compose logs. Prefix with + instead of * when you want to require at least one argument.

Dependencies read left to right and run before the body. check: fmt lint runs fmt, then lint, then whatever check itself contains — here nothing, so check is a pure aggregate. run: migrate guarantees the database is migrated before the server starts. Because just has no timestamp logic, a dependency runs every time by default; it is a sequencing tool, not a caching one. That is usually what you want for task-running, and when it is not, you know exactly where you stand.

Discoverability is the feature that gets teams to adopt it

Type just with no arguments in a project with the default recipe above and you get a formatted list of every public recipe with its doc comment as the description. New joiners stop asking “how do I run the tests here” because the answer is just. That single behaviour is why teams that try just tend to keep it: the commands document themselves, and the documentation cannot drift from the commands because they are the same file.

Two refinements make the menu clean. Recipes whose names start with an underscore, or that carry the [private] attribute (available since just 1.10.0), are hidden from just --list — perfect for helpers like _confirm that only exist to be depended on. And just walks up the directory tree to find a justfile, so a recipe invoked from a deep subdirectory still runs against the project root; if you genuinely want separate command sets per directory, drop a justfile in each and just uses the nearest one. This pairs naturally with the other tooling that should be one command away — a pre-commit baseline behind just check, or the deploy and rollback steps you already track when instrumenting the DORA four.

Where just beats a Makefile, and where it stays out of the way

For a regulated firm the governance argument is quiet but real: a justfile is a single, reviewable, version-controlled record of the operations a team performs by hand, with a strict shell and named arguments that make each one reproducible. When an auditor or a new engineer asks how a service is built, tested, migrated or reset, the answer is one file and one command. The same discipline that makes an operation safe to repeat applies here — a named recipe run the same way every time beats a one-liner someone half-remembers from their shell history.

just wins wherever the task is “run this command, correctly, with these arguments.” It stays out of the way — correctly — the moment the task is “figure out what actually needs rebuilding.” Use just for the commands your team types; keep your build system for the builds. The trap is asking either one to be the other.

Free interactive tool

Website compliance checklist

What your site has to do, based on what it actually does

Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.

Most technology problems are not technology problems. They are control problems.

The systems exist. The investment has been made. The question is whether leadership can understand, direct, evidence, and sustain what those systems produce. Find out where control exists — and where it only appears to.