Build tools are one of those engineering choices that look small until they are not small anymore.
At first, you just need a way to run tests. Then you add code generation. Then
there is a Docker image. Then CI needs the same steps as laptops. Then one team
needs TypeScript, another needs Go, another needs Python, and someone quietly
adds a shell script named build2.sh because the first build script was too
scary to touch.
That is how a build system becomes architecture.
When teams compare Bazel, Make, and Just, they are often comparing three different jobs:
- Bazel is a real build system for large, multi-language, cache-sensitive codebases.
- Make is a classic dependency-driven build tool that is still very useful when the graph is understandable and the environment is mostly Unix-shaped.
- Just is a command runner, not a build system, and that distinction is the whole point.
The mistake is asking, "Which one is best?" The better question is, "What kind of coordination problem does this repository actually have?"
The Short Version
If you want the decision in one table, start here.
| Situation | Best Default | Why |
|---|---|---|
| Small project with common developer commands | Just | Clear recipes, good ergonomics, less Makefile ceremony |
| C/C++ project with simple local dependencies | Make | Mature, ubiquitous, dependency-aware, already understood by many engineers |
| Polyglot monorepo with expensive builds | Bazel | Hermetic actions, remote caching, precise dependency graph |
| CI wrapper for lint/test/format commands | Just or Make | Keep CI readable without pretending every command is a build target |
| Open source project where contributors may not install extra tools | Make | Almost always present in Unix-like development environments |
| Organization trying to standardize many build pipelines | Bazel, carefully | Strong payoff when the team can afford migration and ownership |
My bias: use the simplest tool that honestly models the problem. Do not adopt Bazel because your build feels messy. Adopt Bazel when your build graph, cache needs, language mix, and team scale justify Bazel's operating cost. Do not use Make as a dumping ground for every task under the sun. Do not ask Just to be a build system.
Each tool is good when it is allowed to be itself.
What Bazel Is Good At
Bazel is built around the idea that the build should know its inputs, outputs, tools, and dependencies precisely. That is why Bazel discussions quickly get into hermeticity, remote caching, sandboxing, action graphs, and remote execution.
The official Bazel docs describe hermetic builds as builds that isolate the build from host-machine differences and treat source code plus declared inputs as the basis for repeatable output. That is the heart of the value proposition: if the build system knows exactly what an action depends on, it can cache, parallelize, and reproduce that action much more aggressively.
That matters when:
- Builds are expensive enough that caching changes developer behavior.
- CI spends a meaningful amount of time rebuilding work that someone else already built.
- Multiple languages live in the same repository.
- Generated code and shared libraries create complicated dependencies.
- "Works on my machine" is no longer an amusing local problem.
- You want a path toward remote execution, not just faster local scripts.
Bazel's remote cache can reuse build outputs from another user's build or from CI when the inputs match. That can be a real productivity lever for large teams. It can also be a very expensive way to learn that your build was never as deterministic as everyone hoped.
That is the tradeoff with Bazel: it rewards discipline and exposes sloppiness.
Bazel Costs More Than The Binary
Bazel is not just another command in the toolchain. It changes how engineers describe dependencies, structure packages, write tests, generate code, and think about build reproducibility.
The migration cost is not only "write BUILD files." It is:
- Teaching engineers how Bazel thinks.
- Maintaining language rules and toolchains.
- Debugging sandbox differences.
- Deciding how third-party dependencies enter the graph.
- Updating CI and developer documentation.
- Handling IDE integration.
- Owning the remote cache or remote execution story if you go that far.
For a large codebase, that cost can be completely worth it. For a small service with a dozen straightforward commands, it can be technical pageantry.
Bazel is best when there is an owner. Not a hero who happens to understand it, but an actual team or durable ownership path. A neglected Bazel setup can become as confusing as any pile of shell scripts, except now the shell scripts have graph theory.
What Make Is Good At
Make has survived because the core model is still useful: define targets, declare prerequisites, and let the tool decide what needs to be rebuilt. The GNU Make manual still frames the job plainly: Make determines which pieces need to be recompiled and issues the commands to do it.
That model works well when your build has real file dependencies:
app: main.o config.o
cc -o app main.o config.o
main.o: main.c config.h
cc -c main.c
There is a reason Make became part of the engineering wallpaper. It is boring, portable, scriptable, and widely understood. For C projects, embedded work, traditional Unix tooling, and small repositories, that boringness is a feature.
Make is also a good lightweight interface for common project commands:
.PHONY: test lint format
test:
pytest
lint:
ruff check .
format:
ruff format .
That pattern is everywhere because it works.
Where Make Gets Weird
Make starts to hurt when teams forget what it is. It is a dependency-oriented build tool with some task-runner habits. It is not a modern programming language. It is not a cross-platform workflow engine. It is not a substitute for clear build architecture.
The rough edges are familiar:
- Tabs matter in recipe lines.
- Shell behavior can surprise people.
- Variable expansion has its own personality.
- Cross-platform support gets awkward.
- Large Makefiles can become folklore.
- Phony targets do not model real build outputs.
The biggest Make smell is a file full of phony targets where none of the dependencies are real. At that point you may not be using Make as a build tool. You may be using it as a command menu. That can still be fine, but it is worth being honest about it.
If all you need is make test, make lint, and make docker-build, Make is a
reasonable choice. If you are building a complex polyglot dependency graph and
trying to make CI fast through accurate caching, Make is probably the wrong
level of abstraction.
What Just Is Good At
Just is refreshingly direct. Its own
manual calls it a command runner. Recipes live
in a justfile, the syntax is inspired by Make, and the goal is to save and run
project-specific commands.
That sounds modest. Good. Modest tools are underrated.
A justfile can make a project much easier to approach:
default:
just --list
test:
pytest
lint:
ruff check .
format:
ruff format .
dev:
uvicorn app.main:app --reload
For developer experience, this is lovely. New engineer joins the project, runs
just, and sees the common workflows. CI can call the same recipes. The README
can point at a short list of executable commands instead of a paragraph of
"first do this, unless you are on macOS, and then maybe..."
Just is especially appealing for:
- Application repositories where the real build is handled by language tools.
- Projects that want a clean command menu.
- Teams tired of Make syntax for non-build tasks.
- Cross-platform command ergonomics.
- Replacing fragile README command sequences with executable recipes.
Just Is Not A Build System
The important limitation is the same as the benefit: Just is not trying to model
a build graph. It will run commands for you. It will not understand that
main.o is stale because config.h changed. It will not give you Bazel-style
remote caching. It will not make an undisciplined build reproducible.
That makes Just an excellent front door and a poor foundation for a complicated build graph.
A healthy pattern is to let Just orchestrate tools that already know their domain:
test:
cargo test
frontend:
npm run build
image:
docker build -t example/app .
That is not pretending Just knows Rust, Node, or Docker. It is giving humans a consistent interface to the tools that do.
The Real Decision: Graph, Interface, Or Platform?
Most build-tool confusion comes from mixing three problems.
First, there is the build graph problem. What depends on what? What changed? What can be cached? What can run in parallel? What output should exist after the command finishes? Bazel is strongest here. Make can handle this for many traditional builds. Just is not meant for this job.
Second, there is the developer interface problem. How does someone discover and run the common workflows? How do we keep local commands and CI commands from drifting apart? Just is excellent here. Make is common here. Bazel can expose commands, but using Bazel only as a command menu is usually overkill.
Third, there is the platform standardization problem. How do many teams share build rules, test conventions, toolchains, caching, and CI behavior? Bazel can be very strong here if the organization commits to it. Make and Just can standardize command names, but they do not create the same controlled build platform.
Before picking a tool, decide which problem is hurting you.
Migration Advice
If a team is already unhappy with its build, I would not start by arguing about tools. I would start with an inventory.
Write down:
- The commands developers run every day.
- The commands CI runs.
- The slowest parts of the build.
- The flakiest parts of the build.
- The places where local and CI behavior differ.
- The generated files, codegen steps, and hidden dependencies.
- The language ecosystems involved.
- The number of people affected by a change.
Then pick the smallest useful move.
If the problem is command discoverability, add a justfile or small Makefile.
If the problem is a messy C build, clean up Make dependencies before reaching
for something larger. If the problem is a monorepo where CI rebuilds the world
and nobody trusts incremental behavior, prototype Bazel in one representative
slice before starting a grand migration.
The prototype matters. Bazel sales pitches are easy. Bazel migrations are where the truth lives.
Practical Recommendations
For a small application repository, I would usually start with Just. Put the
common workflows in one place: test, lint, format, run, build,
docker, maybe ci. Let language-specific tools do the actual work. This is a
developer-experience win with low ceremony.
For an open source Unix-friendly project, Make is still hard to beat. A clear
Makefile gives contributors familiar entry points without requiring another
tool. If you need real file dependency behavior, Make earns its keep.
For a serious monorepo, especially one with multiple languages and expensive CI, Bazel deserves a real look. Do not sneak it in as a weekend cleanup. Treat it as engineering infrastructure. Assign ownership. Define success criteria. Measure build times, cache hit rates, CI behavior, and developer friction.
For teams using AI coding agents, build commands matter even more. Agents need
repeatable ways to test their changes, and humans need reviewable signals. A
small just test or make test target can make agent-assisted workflows less
chaotic. For the human side of that loop, see
How to Use AI Coding Agents Without Losing Engineering Judgment.
Common Mistakes
The first mistake is choosing Bazel because the current build is messy. Bazel will not remove complexity. It will force you to name it. That can be valuable, but it is not free.
The second mistake is using Make for every project command and then acting surprised when the Makefile turns into a strange little programming language. Make can do a lot. That does not mean it should.
The third mistake is treating Just as too small to matter. A good command runner can dramatically improve day-to-day developer experience. The fact that it is not a build system is not a flaw. It is the product boundary.
The fourth mistake is letting CI and local development drift apart. Whatever tool you pick, developers should be able to run a meaningful version of the CI checks locally. Otherwise the build system is just a remote disappointment machine.
The Bottom Line
Use Bazel when the build graph is the product. Use Make when file dependencies, portability, and Unix familiarity are the right level of power. Use Just when the team needs a clean, humane command interface.
Real engineering teams do not need fashionable build tools. They need builds that are understandable, repeatable, fast enough, and owned by someone who will still care about them six months from now.
That is less glamorous than a tool debate, but it is much closer to how good developer productivity actually happens.
For more technical notes and practical engineering tradeoffs, visit Slaptijack.