Makefile vs Justfile
What is great about Make?
There is not much to say about Make’s availability: it has been around since 1976 and is installed by default on many Unix-like systems.
That makes it the reference point.
If you introduce another tool, it should solve a problem that Make does not solve well, or provide a noticeably better developer experience.
A few points are worth mentioning:
Make is everywhere, and every developer or system administrator should probably have used it at least once.
Make is both a build tool and a task runner.
It understands dependencies and can decide not to rebuild a target when its dependencies are already up to date.
just, on the other hand, deliberately focuses on being a task runner.
That difference is important.
Make was designed to build files efficiently.
just was designed to run commands conveniently.
What is great about Just?
Here are some features that just provides natively and that are either unavailable or more cumbersome to implement with Make.
Interactive recipe selection
1just --choose
This lets you choose a recipe interactively.
Define the working directory
1just --justfile ~/.user.justfile --working-directory ~
This is particularly useful when using a global or shared justfile.
Early error detection
One thing I appreciate is that just catches many mistakes before executing the recipe.
For example:
1bash: line 1: repository: unbound variable
2error: Backtick failed with exit code 127
3 |
44 | REPOSITORY := `if [ -n $repository ]; then echo "$repository"; else echo "github.com"; fi`
5 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Getting an explicit error immediately is much better than discovering a problem halfway through a long task.
Shebang recipes
This is a big one.
A recipe can directly contain a Bash, Python, Node.js, or other script without requiring a separate script file.
1# Python directly inside your justfile
2hello name:
3 #!/usr/bin/env python3
4 import sys
5
6 name = "{{name}}"
7 print(f"Hello {name}!")
No scripts/hello.py, no wrapper — everything stays inside the justfile.
For small scripts and project utilities, this is extremely convenient.
Built-in recipe documentation
If you add a comment before a recipe, just --list can display it automatically.
For example:
1Available recipes:
2 env repository='github.com' # env
3 test # Test
This makes it easy to build a small, self-documenting project CLI.
Custom help recipes
You can also create your own hidden recipe and use it as the default entry point:
1_help:
2 @printf "Some Title"
3 @just --list --unsorted
4 @printf "Some Extra infos"
Doing something similar with Make usually requires additional .PHONY targets and custom shell commands.
Hidden recipes
Recipes can be hidden from the normal list when they are only implementation details.
The [private] attribute is useful for this:
1[private]
2some-internal-task:
3 echo "Internal task"
Generate shell aliases
You can automatically create aliases for all available recipes:
1for recipe in `just -f ~/.justfile --summary`; do
2 alias $recipe="just -f ~/.justfile -d. $recipe"
3done
This allows a justfile to behave almost like a small CLI.
Recipe ordering
Recipes can be listed alphabetically:
1just --list
or in the order in which they appear in the file:
1just --list --unsorted
Recipe arguments
With Make, passing parameters often looks something like this:
1make something -e CHOICE=test
With just, recipe arguments are explicit:
1just something test
Inside the justfile, the recipe can declare the parameters it expects.
This makes the command-line interface much clearer.
Recipe autocompletion
just can provide completion for available recipes.
For example:
1$ just
2blank -- Args: PROJECT *GROUP # Create a new empty project on remote repository.
3build -- Args: PROJECT NAMESPACE # Build collection locally.
4clone -- Args: PROJECT # Clone a project from repository keeping directory structure for ansible.
5clone_all -- Args: *GROUP # Git clone all projects from your repository, or if argument provided only from specific group.
6init -- Args: PROJECT *GROUP # Create a new ansible collection on repository.
7install -- Args: PROJECT *VERSION # Install an ansible collection. (if PROJECT is an artifact .tar.gz install local)
8local -- Args: PROJECT NAMESPACE # Create a new ansible collection on localhost (not on repository like function below).
9release -- Args: PROJECT *VERSION # Release collection on your repository to the given version in command or in galaxy.yml.
10role -- Args: GROUP PROJECT ROLE # Create a new ansible role inside an existing collection.
For larger projects, this turns the justfile into something close to a discoverable internal CLI.
Syntax checking
just validates the syntax of the file and points directly to errors.
This is especially useful as the number of recipes grows.
Recipes in arbitrary languages
Recipes can be implemented in Bash, Python, Node.js, and many other languages.
This is one of the features I find most useful because it avoids creating lots of tiny helper scripts.
Recipe groups
Recipes can be grouped using attributes such as:
1[group('Development')]
For example:
1➜ Colt git:(main) just
2Available recipes:
3 [Development]
4 compile # Build the binary.
5 test # Run the default Colt suite and lightweight repository checks.
6 test-unit # Fast unit lane: parsers, config, command generation, API mapping. No network.
7 test-integration # GITEA_PORT/FORGEJO_PORT (defaults 13000/13001), COLT_IT_KEEP=1 to debug.
8 test-bdd # Run every active deterministic BDD scenario (local fixtures only).
9 test-bdd-blackbox # Build and smoke-test the real Colt binary in a sandbox.
10 bdd-coverage # Explicitly regenerate deterministic requirement-to-scenario coverage.
11 check-tools # the image with a read-only workspace and no Kubernetes credential mount.
12
13 [Execution Environment]
14 build # Build the Execution Environment container image.
15 push # Push the Execution Environment image to the private registry.
16 deploy # Deploy the toolkit via the Helm chart (podman play kube).
17 destroy # Tear down the deployed toolkit pod.
18 redeploy # Destroy then redeploy.
19 connect # Launch the EE toolkit container and drop into an interactive shell.
This makes large justfiles much easier to navigate.
Overall, just remains focused on one thing:
being a task runner.
Most of its features are designed specifically around that goal.
Justfile limitations
just is not perfect.
There are still a few limitations and behaviors worth understanding.
Environment variables
One limitation I originally encountered involved optional environment variables.
I wanted to define a default behavior while still allowing the user to override it.
My first attempt looked like this:
1bash: line 1: repository: unbound variable
2error: Backtick failed with exit code 127
3 |
44 | REPOSITORY := `if [ -n $repository ]; then echo "$repository"; else echo "github.com"; fi`
5 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
6
7# Get the same error if the environment variable is not defined.
8REPOSITORY := env_var('REPOSITORY')
At first, I considered this a limitation.
However, just already provides a better solution:
1REPOSITORY := env_var_or_default('REPOSITORY', "github.com")
So this particular problem was mostly caused by my initial approach rather than by just itself.
Variables inside backticks
Another limitation concerns variables evaluated inside backtick expressions.
For example:
1set shell := ["bash", "-uc"]
2
3REPO := "github.com"
4TEST := "https://" + REPO
5TEST2 := `curl https://{{TEST}}`
6
7# Test
8test:
9 #!/usr/bin/env bash
10 echo {{TEST}}
11 echo {{TEST2}}
Depending on how values are evaluated, this kind of expression can become awkward.
In general, I prefer to keep complex runtime logic inside recipes instead of trying to put too much shell logic into top-level variable declarations.
That also makes the justfile easier to read.
Makefile limitations for task running
Make is extremely powerful, but some of its design choices become awkward when the goal is simply to create a project CLI.
One example is documentation.
To generate a convenient help command for .PHONY targets, you often end up writing something like this:
1.PHONY: prerequis
2## Install all prerequisites for this Ansible Collection.
3prerequis:
4 $(MAKE) -C ./scripts/prerequis all
5
6# Keep this at the end of your Makefile
7.DEFAULT_GOAL := show-help
8
9# Inspired by <http://marmelab.com/blog/2016/02/29/auto-documented-makefile.html>
10.PHONY: show-help
11show-help:
12 @echo "$$(tput bold)Available rules:$$(tput sgr0)"
13 @echo
14 @sed -n -e "/^## / { \
15 h; \
16 s/.*//; \
17 :doc" \
18 -e "H; \
19 n; \
20 s/^## //; \
21 t doc" \
22 -e "s/:.*//; \
23 G; \
24 s/\\n## /---/; \
25 s/\\n/ /g; \
26 p; \
27 }" ${MAKEFILE_LIST} \
28 | LC_ALL='C' sort --ignore-case \
29 | awk -F '---' \
30 -v ncol=$$(tput cols) \
31 -v indent=19 \
32 -v col_on="$$(tput setaf 6)" \
33 -v col_off="$$(tput sgr0)" \
34 '{ \
35 printf "%s%*s%s ", col_on, -indent, $$1, col_off; \
36 n = split($$2, words, " "); \
37 line_length = ncol - indent; \
38 for (i = 1; i <= n; i++) { \
39 line_length -= length(words[i]) + 1; \
40 if (line_length <= 0) { \
41 line_length = ncol - indent - length(words[i]) - 1; \
42 printf "\n%*s ", -indent, " "; \
43 } \
44 printf "%s ", words[i]; \
45 } \
46 printf "\n"; \
47 }' \
48 | cat
It works.
But compared with:
1just --list
the amount of boilerplate is significant.
This illustrates the main difference between the two tools.
Make can absolutely be used as a task runner, but many features that just provides directly have to be implemented manually.
Conclusion
The list of features is quite long, and the result is a very pleasant tool for organizing project tasks.
just gives you:
- Recipe arguments
- Built-in documentation
- Groups
- Private recipes
- Shell completion
- Shebang recipes
- Syntax validation
- Multiple scripting languages
- A clean CLI-oriented syntax
It tries to avoid much of the complexity and many of the historical conventions of Make.
With Make, the Make syntax and the shell syntax are often mixed together, and reading a large existing Makefile can become tedious.
That does not make Make obsolete.
Make is still the better tool when the actual problem is building files based on dependencies and timestamps.
But when the goal is simply:
run a well-defined set of project commands
I now prefer just.
Other tips
Build a small CLI
A global justfile can easily become a personal command-line interface:
1alias acme='just --justfile ~/acme/cli/justfile'
With a default recipe:
1[private]
2@default:
3 just --list
4
5# Show architecture and OS name
6@os-info:
7 echo "Arch: {{arch()}}"
8 echo "OS: {{os()}}"
You can then use:
1acme
2acme os-info
instead of maintaining a collection of unrelated shell scripts.
Use platform-specific recipes
just can also provide recipes that differ depending on the operating system.
For example:
1[private]
2@default:
3 just --list
4
5# Show architecture and OS name
6@os-info:
7 echo "Arch: {{arch()}}"
8 echo "OS: {{os()}}"
9
10# List systemd services
11[linux]
12@list-systemd-services:
13 systemctl list-units --type=service
14
15# Get the size of a folder
16[linux]
17[no-cd]
18get-folder-size path:
19 du -sh {{path}}
20
21# Get the size of a folder in MB
22[windows]
23[no-cd]
24get-folder-size path:
25 (Get-ChildItem "{{path}}" -Recurse -Force | Measure-Object -Property Length -Sum).Sum / 1MB
This makes it possible to expose the same logical task while implementing it differently on Linux and Windows.
Embed Python directly
Small Python utilities can live directly inside the justfile:
1# Scale a JPG image by 50%
2[no-cd]
3scale-jpg path:
4 #!/usr/bin/env python3
5
6 import PIL.Image
7 image = PIL.Image.open("{{path}}")
8 factor = 0.5
9 image = image.resize((round(image.width * factor), round(image.height * factor)))
10 image.save("{{path}}.s50.jpg")
Use Nix for script dependencies
You can even combine just with Nix when a recipe requires additional dependencies:
1# Scale a JPG image by 50%
2[no-cd]
3scale-jpg path:
4 #! /usr/bin/env nix-shell
5 #! nix-shell -i python3 -p python3Packages.pillow
6
7 import PIL.Image
That is a particularly interesting combination:
just defines the task, while Nix provides the execution environment.
Bonus point
I usually start my projects from my
project template, which already includes a structured justfile.
For example:
1➜ project-template git:(main) just
2Available recipes:
3 [Development]
4 compile # Build the binary. [This is just an example]
5 test # Run check the developer/support environment.
6 test-bdd # Run every active deterministic BDD scenario (local fixtures only).
7 check-tools # the image with a read-only workspace and no Kubernetes credential mount.
8
9 [Execution Environment]
10 build # Build the Execution Environment container image.
11 push # Push the Execution Environment image to the private registry.
12 deploy # Deploy the toolkit via the Helm chart (podman play kube).
13 destroy # Tear down the deployed toolkit pod.
14 redeploy # Destroy then redeploy.
15 connect # Launch the EE toolkit container and drop into an interactive shell.
This is where I find just particularly useful.
The justfile becomes the common entry point for the project:
1Developer
2 │
3 ▼
4 just
5 │
6 ├── test
7 ├── compile
8 ├── build
9 ├── deploy
10 └── destroy
The underlying tools may change, but the developer interface remains simple and discoverable.




