just: A Modern Command Runner That Makes Makefiles Obsolete
just: A Modern Command Runner That Makes Makefiles Obsolete
Every project accumulates a set of commands that developers run repeatedly: build, test, lint, deploy, start the dev server, run database migrations. Make has been the traditional tool for wrapping these into named targets, but Make was designed to compile C programs in 1976. It uses tabs as syntax, has confusing automatic variables like $@ and $<, and silently swallows errors in ways that waste hours of debugging.

just is a command runner that keeps the good parts of Make (named recipes in a file, dependency declarations, running from the project root) and throws away the bad parts (tab sensitivity, implicit rules, file-based targets). It is not a build system. It is a command runner, and it does that one job well.
Installation
# macOS
brew install just
# Fedora
sudo dnf install just
# Ubuntu/Debian
sudo apt install just
# Arch
pacman -S just
# Cargo
cargo install just
# Via mise
mise use just@latest
# Via npm (for JavaScript projects)
npx --yes @anthropic/just
Basic Usage
Create a file called justfile (no extension) in your project root:
# Run the development server
dev:
npm run dev
# Run all tests
test:
npm test
# Lint and format
lint:
npx biome check --write .
# Build for production
build:
npm run build
# Deploy to production
deploy: build
rsync -avz dist/ server:/var/www/app/
Run a recipe:
just dev # runs the 'dev' recipe
just test # runs 'test'
just deploy # runs 'build' first (dependency), then 'deploy'
just # runs the first recipe (dev) by default
just --list # shows all available recipes with descriptions
The comments above each recipe become descriptions in just --list. This turns your justfile into self-documenting project automation.
Why just Over Make
No Tab Sensitivity
Make requires tabs for indentation. Spaces silently break things. just uses consistent 4-space indentation (or tabs -- your choice). This alone prevents a class of frustrating invisible bugs.
No File-Based Targets
Make tracks file modification times to decide whether to rebuild. This is powerful for C compilation but meaningless for running tests or starting servers. In Make, you end up with .PHONY declarations everywhere. just has no concept of file targets -- every recipe is always a command to run.
Better Error Messages
When something goes wrong in Make, the error message often references internal Make concepts. just gives clear errors that point to the line in your justfile:
error: Unknown recipe `depoly`
Did you mean `deploy`?
Variables and String Interpolation
version := "1.2.0"
registry := "ghcr.io/myorg"
# Build and tag a Docker image
docker-build:
docker build -t {{registry}}/myapp:{{version}} .
# Push the image
docker-push: docker-build
docker push {{registry}}/myapp:{{version}}
Variables use {{}} interpolation, which is visually distinct from shell variables ($VAR). In Make, the interaction between Make variables and shell variables is a constant source of confusion.
Practical Patterns
Recipes with Arguments
# Run a specific test file
test file:
pytest {{file}} -v
# Deploy to a specific environment
deploy env="staging":
./scripts/deploy.sh {{env}}
# Create a new migration
migration name:
alembic revision --autogenerate -m "{{name}}"
Usage:
just test tests/test_api.py
just deploy production
just deploy # uses default: staging
just migration "add users table"
Environment Variables
# Load from .env file
set dotenv-load
# Set for all recipes
export DATABASE_URL := "postgres://localhost/myapp"
# Set per-recipe
test:
DATABASE_URL="postgres://localhost/myapp_test" pytest
The set dotenv-load directive reads your .env file automatically, making just aware of the same environment your application uses.
Conditional Recipes
# OS-specific commands
install:
#!/usr/bin/env bash
if [[ "$(uname)" == "Darwin" ]]; then
brew install dependencies
elif [[ -f /etc/fedora-release ]]; then
sudo dnf install dependencies
else
sudo apt install dependencies
fi
Multi-Line Recipes with Shebang
By default, just runs each line of a recipe as a separate shell command. For multi-line scripts, use a shebang:
# Generate a release
release version:
#!/usr/bin/env bash
set -euo pipefail
echo "Releasing version {{version}}"
git tag -a "v{{version}}" -m "Release {{version}}"
git push origin "v{{version}}"
gh release create "v{{version}}" --generate-notes
The shebang approach means the entire recipe runs as one script, so variables, conditionals, and loops work naturally.
Recipe Dependencies
# Dependencies run before the recipe
deploy: test lint build
./deploy.sh
# Dependencies can also pass arguments
ci: (test "unit") (test "integration") lint build
test suite="all":
pytest -m {{suite}}
Private Recipes
Prefix a recipe with _ to hide it from just --list:
# Public recipe
build: _clean _compile _bundle
echo "Build complete"
# Private helpers
_clean:
rm -rf dist/
_compile:
tsc
_bundle:
esbuild src/index.ts --bundle --outdir=dist
Confirming Dangerous Commands
# Require confirmation before running
[confirm("Are you sure you want to reset the database?")]
db-reset:
dropdb myapp && createdb myapp && alembic upgrade head
Project-Specific justfiles
Python Project
set dotenv-load
# Run the development server
dev:
uv run uvicorn app.main:app --reload
# Run tests with coverage
test:
uv run pytest --cov=app --cov-report=term-missing
# Lint and format
lint:
uv run ruff check --fix .
uv run ruff format .
# Type check
typecheck:
uv run mypy app/
# Run all checks (CI equivalent)
check: lint typecheck test
# Database migrations
migrate:
uv run alembic upgrade head
migration name:
uv run alembic revision --autogenerate -m "{{name}}"
TypeScript/Node Project
# Start dev server
dev:
bun run dev
# Run tests
test:
bun test
# Lint and format
lint:
bunx biome check --write .
# Build
build:
bun run build
# Database
db-push:
bunx drizzle-kit push
db-studio:
bunx drizzle-kit studio
# Full CI check
ci: lint test build
Docker Project
registry := "ghcr.io/myorg"
image := "myapp"
# Build the image
build tag="latest":
docker build -t {{registry}}/{{image}}:{{tag}} .
# Run locally
run: build
docker compose up
# Push to registry
push tag="latest": (build tag)
docker push {{registry}}/{{image}}:{{tag}}
# Shell into the container
shell:
docker compose exec app /bin/bash
Settings
just has global settings that go at the top of your justfile:
# Use bash with strict mode
set shell := ["bash", "-euo", "pipefail", "-c"]
# Load .env files
set dotenv-load
# Allow recipes to fail without stopping
set ignore-errors # (use sparingly)
# Run from the justfile directory, not the CWD
set working-directory := "."
# Use PowerShell on Windows
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
The set shell directive with bash strict mode is strongly recommended. It catches undefined variables, pipe failures, and other silent errors that would otherwise go unnoticed.
just vs. Make vs. Task vs. npm Scripts
just vs. Make: just is simpler, has better error messages, and does not carry Make's file-target legacy. Use Make only if you genuinely need file-based dependency tracking (compiling C/C++, complex build graphs).
just vs. Task (go-task): Task uses YAML configuration and has built-in file-watching and checksumming. just uses a simpler plaintext format and focuses purely on running commands. Task is more featureful; just is more predictable.
just vs. npm scripts: npm scripts work for JavaScript projects but become unwieldy beyond simple commands. You cannot define dependencies between scripts, pass arguments cleanly, or use variables. just works with any language and any toolchain.
The Bottom Line
just is the right tool for the gap between "I have some commands I run often" and "I need a full build system." It replaces the common pattern of a Makefile full of .PHONY targets with a cleaner syntax that new team members can read without studying Make's manual. Put a justfile in your project root, move your README's "Development Setup" commands into named recipes, and your project becomes self-documenting.