I’ve been a huge fan of GNU Make for quite some time. I use it as a top-level orchestration and automation tool for managing all my projects, no matter the size.
I’ve written before about how I use Make, but in this post I’ll highlight a few key improvements to the workflow that make it much more lightweight and enjoyable to use.
Briefly: Why layer another tool on top?
Two main benefits:
- The tool is both documentation and automation
- The tool operates a layer above your project-specific workflow
I’ve got my own reasons for preferring Make, but the automation benefit applies to any similar tool (e.g. just). By writing a Makefile
, I am documenting what things I need to be able to do on the project, as well as how to do them.
For example, I was working on a one-off project with H2O Wave (don’t ask) earlier in the year, and then had to come back a month later to fix a bug. Running make help
helpfully tells me that I can do make run
, and looking at the Makefile
tells me that it will execute wave run [app name] [all my args]
.
Using Make also allows me to automate meta-management of my project. Because it operates at a layer above my virtual environment tooling, I can use a single command to trash and recreate an existing .venv/
, or easily get set up for the first time on a new machine, including ensuring that I’ve got the right Python interpreter installed.
What’s changed about my workflow in 2025
I’ve already written a a post in early 2024 on using Make, but two things have changed since then:
- I’ve drastically simplified how I write my Makefiles
- I use
uv
for all interpreter and venv management
First, simplifying the Makefiles was really key. Trying to manage complex cross-platform projects gets really painful, really fast. By avoiding fancy help
rules and pushing complex project automation down to other tooling, I can use Make as my sole interface and entrypoint for managing a project.
Second, switching to uv has been an enormous quality of life improvement. It’s seriously blazing fast, but it also strives to be pip
compatible. Having previously been burned by going all-in poetry, which has its own way of defining your project and how you build your wheels, I was wary of getting locked into something that would lock me into its own way of doing things. But with uv
I can simply replace something like uv pip install '.[dev]'
with pip install '.[dev]'
if I ever need to, and everything just works.
Lastly, uv
has the ability to create virtual environments and manage Python interpreters. I can use uv venv --seed --managed-python --python 3.10
on any platform, and uv
will create a .venv/
directory with Python 3.10, installing a uv-managed Python 3.10 interpreter on my system if needed.
My workflow
Prerequisites
These tools must be installed at the system level in order to support this workflow. They can all be installed via any standard package manager (including choco
on Windows!), and I usually need them installed before I can do any real work, anyway:
- Make (duh)
- Ripgrep
- uv
The Makefile
This is the starting point for any new project. Even if I add more rules later, these 4 rules usually remain as you see them here.
PY=3.10
help:
@rg -oN --color=never '^[^: ]+' Makefile
.venv:
@uv venv --seed --managed-python --python $(PY)
install: .venv
@uv pip install -e '.[dev]'
destroy:
@uvx python -c "import shutil; shutil.rmtree('.venv', ignore_errors=True)"
Quick note: It’s probably not obvious when this page is rendered to HTML, but the regex query includes a space and tab character inside the square brackets.
FAQ
How do you capture complex project automation in a cross-platform way?
It’s not uncommon for large projects to have complex automation workflows. Trying to capture complex logic in a Makefile
and make it work across operating systems is a sure path to insanity. I delegate that work to another tool that Make can manage. For Python projects, that tool is invoke.
For example, packaging a Python wheel for one project involves the following:
- Binary compilation of several sub-packages with Nuitka
- Generating
.pyi
files with mypy’s stubgen - Including a collection of static files
This is a complex, multi-step workflow across several different locations on the filesystem, but I can capture that logic in Invoke’s tasks.py
file. My Makefile
then simple needs a build
rule that calls invoke build
.