From Makefile to just: A Modern Task Runner¶
Makefiles have been around for a long time; they work, but they're stuck in 1976. Let me show you something better.
In this 10-minute tutorial, we'll migrate a real Makefile to just—a modern task runner that actually makes sense. You'll see why developers are switching and how to do it yourself.
Prerequisites¶
You need:
- Basic command line knowledge
- A project with a Makefile (or follow along with our example)
- 10 minutes
We'll install:
- just (the task runner)
The Problem with Makefiles¶
Here's a typical Makefile from a Python project:
.PHONY: install test lint clean
install:
pip install -r requirements.txt
test:
pytest tests/
lint:
black .
flake8 .
mypy .
clean:
find . -type d -name __pycache__ -exec rm -rf {} +
find . -type f -name "*.pyc" -delete
serve:
python manage.py runserver
deploy: test lint
./scripts/deploy.sh
Issues with this:
.PHONYconfusion - Forget it once and tasks break- Tab sensitivity - Must use tabs, not spaces (invisible bugs)
- Cryptic syntax -
$@,$<,$^everywhere - Poor error messages - "Missing separator" means what?
- No arguments - Can't do
make deploy staging - Silent failures - Commands fail but Make continues
Real story: I once spent 30 minutes debugging why my Makefile task wasn't running. The issue? I used spaces instead of tabs. Invisible characters shouldn't break your build.
Step 1: Install just¶
Installing just is straightforward:
macOS:
Linux:
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
Windows:
Verify installation:
Done. No configuration needed.
Step 2: Create Your First justfile¶
Create a file named justfile (no extension) in your project root:
Unlike Makefiles, justfiles:
- Use spaces (like normal humans)
- Have clear syntax
- Show helpful errors
- Support arguments naturally
Let's start simple:
# justfile
# List all available commands
default:
@just --list
# Install dependencies
install:
uv sync
# Install with pip (legacy)
install-pip:
pip install -r requirements.txt
# Run tests
test:
uv run pytest tests/
Try it:
just
# Shows: Available recipes:
# default
# install
# install-pip
# test
just install
# Runs: uv sync
just test
# Runs: uv run pytest tests/
Note: If you're using modern Python tooling with pyproject.toml and UV, use uv sync. For legacy projects with requirements.txt, use just install-pip.
Already better! The @ prefix hides command echo (like Make's @ but consistent).
Step 3: Convert Commands One by One¶
Let's convert that Makefile piece by piece.
Simple Commands¶
Makefile:
justfile (modern with UV):
# Start development server
serve:
uv run python manage.py runserver
# Or for legacy projects
serve-legacy:
python manage.py runserver
Better: No .PHONY needed. Comments are clearer. UV handles virtual environments automatically.
Multiple Commands¶
Makefile (legacy tools):
justfile (modern with Ruff):
# Run all linters (modern)
lint:
uv run ruff check .
uv run ruff format --check .
uv run mypy .
# Run all linters (legacy tools)
lint-legacy:
black .
flake8 .
mypy .
Better: Ruff replaces both black and flake8 with a single, faster tool. Cleaner syntax, and UV manages everything.
Commands with Dependencies¶
Makefile:
justfile:
Identical syntax, but just gives better error messages.
Complex Commands¶
Makefile:
.PHONY: clean
clean:
find . -type d -name __pycache__ -exec rm -rf {} +
find . -type f -name "*.pyc" -delete
justfile:
# Clean Python artifacts
clean:
find . -type d -name __pycache__ -exec rm -rf {} +
find . -type f -name "*.pyc" -delete
Same power, readable indentation.
Step 4: Add just Superpowers¶
Now let's use features Make doesn't have.
Recipe Arguments¶
# Deploy to specific environment
deploy ENVIRONMENT:
./scripts/deploy.sh {{ENVIRONMENT}}
# Create new migration
migrate MESSAGE:
uv run python manage.py makemigrations -m "{{MESSAGE}}"
Usage:
Make can't do this. You'd need environment variables or hacky solutions.
Default Values¶
# Serve on custom port (default: 8000)
serve PORT="8000":
uv run python manage.py runserver {{PORT}}
Usage:
Multiline Strings¶
# Show help text
help:
@echo 'Available commands:'
@echo ' install - Install dependencies'
@echo ' test - Run tests'
@echo ' deploy - Deploy to production'
Or better, with here-docs:
help:
@cat << 'EOF'
Available commands:
install - Install dependencies
test - Run tests
deploy - Deploy to production
EOF
Variables¶
python_version := "3.12"
project_name := "myapp"
# Show configuration
info:
@echo "Python: {{python_version}}"
@echo "Project: {{project_name}}"
# Install specific Python version
install-python:
pyenv install {{python_version}}
Shebang Recipes¶
For complex logic, use any language:
# Analyze code quality
analyze:
#!/usr/bin/env python3
import subprocess
import sys
print("Running code analysis...")
# Run multiple checks
checks = ["uv run ruff check .", "uv run mypy ."]
for check in checks:
result = subprocess.run(check, shell=True)
if result.returncode != 0:
print(f"Failed: {check}")
sys.exit(1)
print("✓ All checks passed!")
Make can't do this. You'd need separate scripts.
Step 5: Complete Migration¶
Here's the full justfile replacing our original Makefile:
# justfile - Modern task runner for Python project
# Default: show available commands
default:
@just --list
# Install dependencies
install:
uv sync
# Install with pip (legacy)
install-pip:
pip install -r requirements.txt
# Run tests
test:
uv run pytest tests/ -v
# Run all linters (modern)
lint:
uv run ruff check .
uv run ruff format --check .
uv run mypy .
# Run all linters (legacy)
lint-legacy:
black .
flake8 .
mypy .
# Format code
format:
uv run ruff format .
# Clean Python artifacts
clean:
find . -type d -name __pycache__ -exec rm -rf {} +
find . -type f -name "*.pyc" -delete
rm -rf .pytest_cache htmlcov .coverage
# Start development server
serve PORT="8000":
uv run python manage.py runserver {{PORT}}
# Deploy to environment (runs tests first)
deploy ENVIRONMENT: test lint
@echo "Deploying to {{ENVIRONMENT}}..."
./scripts/deploy.sh {{ENVIRONMENT}}
# Create new migration
migrate MESSAGE:
uv run python manage.py makemigrations -m "{{MESSAGE}}"
uv run python manage.py migrate
# Run development checks
check: lint test
@echo "✓ All checks passed!"
# Show project info
info:
@echo "Project: MyApp"
@echo "Python: 3.12"
@echo "Tools: UV + Ruff"
@just --list
Compare file sizes:
- Makefile: 20 lines +
.PHONYoverhead - justfile: 50+ lines but way more functionality
Modern Python Tooling
This tutorial uses modern tools:
UV - Fast Python package installer and resolver (replaces pip)
Ruff - Lightning-fast linter and formatter (replaces black + flake8 + isort)
Benefits:
- 10-100x faster than traditional tools
- Single tool instead of multiple
- Better error messages
Legacy alternatives included for projects not yet using modern tooling.
Learn more: The Complete Guide to UV
Before/After Comparison¶
Running Commands¶
Makefile (legacy):
justfile (modern with UV):
just install # uv sync
just test # uv run pytest
just lint # uv run ruff
just deploy production # With arguments!
justfile (legacy tools):
Getting Help¶
Makefile:
justfile:
Error Messages¶
Makefile:
(What does that mean?)justfile:
(Crystal clear)Common Patterns¶
Pattern 1: Quick Workflow Commands¶
# Quick development workflow
dev: install
@echo "Starting development environment..."
just serve
# Full CI workflow
ci: install lint test
@echo "✓ CI checks passed"
Pattern 2: Conditional Execution¶
# Only run if files changed
test-changed:
#!/usr/bin/env bash
if git diff --name-only | grep -q "\.py$"; then
uv run pytest tests/
else
echo "No Python files changed, skipping tests"
fi
Pattern 3: Interactive Prompts¶
# Deploy with confirmation
deploy-prod: test lint
#!/usr/bin/env bash
read -p "Deploy to production? (y/N) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
./scripts/deploy.sh production
fi
Testing Your justfile¶
Verify everything works:
# List commands
just
# Try each command
just install
just test
just lint
# Test with arguments
just serve 3000
just deploy staging
# Test dependencies
just check # Should run lint and test
Expected output:
Migration Checklist¶
When converting your Makefile:
- Create justfile in project root
- Copy simple commands first
- Add comments (they're actually readable!)
- Convert .PHONY targets
- Add arguments where useful
- Test all commands
- Update CI/CD to use just
- Update documentation
- Delete Makefile (or keep for compatibility)
Why just Wins¶
After migration, you get:
Better Developer Experience:
- Clear syntax (spaces, not tabs)
- Helpful error messages
- Built-in help (
just --list) - Proper argument support
More Power:
- Variables and interpolation
- Recipe dependencies
- Conditional execution
- Any language in recipes
Less Pain:
- No
.PHONYconfusion - No invisible tab bugs
- No cryptic Make variables
- No silent failures
Real-World Impact¶
Before (Makefile):
After (justfile):
$ just deploy production
error: Recipe `deploy` requires environment argument
Available: staging, production
$ just deploy production
Running tests... ✓
Running lint... ✓
Deploying to production...
✓ Deployed successfully
Clarity matters.
Going Further¶
Once you're comfortable:
- Create aliases:
alias j=just - Add completion:
just --completions bash >> ~/.bashrc - Explore modules: Split large justfiles
- Try
.envsupport: Load environment variables - Check examples:
just --examples
Resources:
Conclusion¶
You've migrated from Makefile to just in 10 minutes.
You now have:
- Cleaner syntax (goodbye tabs)
- Better errors (understand what failed)
- More features (arguments, variables)
- Happier developers (readable files)
Next time you start a project, skip Make and start with just.
Your future self will thank you when you're not debugging invisible tab characters at 2am.
Questions or issues migrating? Email me
Want more tutorials? Check out The Complete Guide to UV for Python Development