Taskfile: A Modern Task Runner That Replaces Make Without the Pain

All the convenience of make, none of the tab-versus-space trauma

I have a long and complicated relationship with make. It is everywhere, it is reliable, and it has been quietly running the world’s builds for nearly fifty years. It also punishes you with significant whitespace, a recipe language that is really shell-inside-make-inside-shell with three layers of escaping, and the eternal indignity of Makefile:12: *** missing separator. Stop. because you typed spaces where a literal tab was required. For most of what people actually use Make for these days — not building C, but running a handful of project commands — Taskfile is the replacement I wish I’d switched to years ago.

Advertisement

Taskfile is a task runner written in Go, configured with a single Taskfile.yml. It ships as one static binary with no runtime dependencies, which means no Python, no Node, nothing to install but task itself. You define named tasks, give them commands, declare dependencies between them, and run task <name>. If that sounds exactly like Make, that’s deliberate — but it’s Make with YAML instead of tabs, real variable handling, built-in file-checksum change detection, and cross-platform behaviour that doesn’t fall apart the moment a Windows developer joins the team.

Installation is a one-liner:

# install the binary (Linux/macOS)
sh -c "$(curl -sL https://taskfile.dev/install.sh)" -- -d -b /usr/local/bin

# verify
task --version

Here is a Taskfile.yml close to what I drop into a typical service repo. Note the desc fields — they’re not decoration, they power task --list:

version: '3'

vars:
  IMAGE: registry.example.com/api
  GIT_SHA:
    sh: git rev-parse --short HEAD

tasks:
  default:
    desc: Show available tasks
    cmds:
      - task --list
    silent: true

  build:
    desc: Build the binary
    sources:
      - "**/*.go"
    generates:
      - bin/api
    cmds:
      - go build -o bin/api ./cmd/api

  test:
    desc: Run the test suite
    deps: [build]
    cmds:
      - go test ./...

  docker:
    desc: Build and tag the container image
    deps: [test]
    cmds:
      - docker build -t {{.IMAGE}}:{{.GIT_SHA}} .
      - docker tag {{.IMAGE}}:{{.GIT_SHA}} {{.IMAGE}}:latest

Run task with no arguments and you get the self-documenting list. Run task docker and it runs test first, which runs build first, exactly like Make’s prerequisites — but the dependency graph is declared in plain deps: rather than buried in target syntax.

Two things make me reach for Taskfile over Make beyond cosmetics.

First, smart change detection. In the build task above, sources and generates tell Task to skip the build entirely if no .go file changed since the last run. Make does this too, but only via mtime comparison, which breaks the moment a file’s timestamp lies (checkouts, container layers, restored caches). Task can use content checksums instead — add method: checksum and it hashes the inputs rather than trusting timestamps. That alone has saved me from a class of “why did this rebuild / why didn’t this rebuild” mysteries.

Second, variables that behave. The GIT_SHA variable above runs a shell command and captures its output once, cleanly. Doing the equivalent in Make involves $(shell ...), deferred-versus-immediate assignment (= versus :=), and the constant question of how many dollar signs you need this time. Task’s templating is Go’s text/template, which is a known quantity with documented functions, not folklore.

You can also include other Taskfiles, which is how I keep a shared base of common tasks and pull it into each repo:

includes:
  common:
    taskfile: ./build/Taskfile.common.yml

Then task common:lint runs the included task. It’s a genuinely sane module system, which is more than Make’s include ever managed.

I won’t pretend this is a clean sweep. If you are doing real incremental compilation of a large C or C++ project with complex pattern rules and a tuned dependency graph, Make is purpose-built for that and Task is not trying to replace it. Make is also guaranteed present on essentially every Unix box ever made, whereas Task is one more binary to install in CI — trivial, but it is a step. And there is a deep bench of Make knowledge in the wild; far fewer people can debug your Taskfile at 2am.

The honest framing: Make is a build system that people abuse as a task runner. Task is a task runner that doesn’t pretend to be a build system. For the “make deploy / make test / make migrate” use case — which is what most modern Makefiles actually are — Task wins on readability and predictability.

If your Makefile is really a command launcher and you’ve been quietly resenting it, yes, switch. The migration is mechanical, the YAML is readable by people who’ve never touched Make, and task --list turns your project’s commands into self-documenting help that new contributors actually use. The cost is a binary in your toolchain and explaining to one greybeard why you abandoned a fifty-year-old standard.

If you’re compiling C with intricate incremental rules, stay on Make — that’s its home turf. For everything else I now reach for a Taskfile by default, and I haven’t typed “missing separator” since.

Advertisement

Related Content

Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.