Why I Switched (Mostly) from Make to go-task
For years, nearly every project I started got a Makefile sooner or later. Not because I was building C programs with complicated dependency graphs, but because make build, make test and make clean are easy to type and easy to remember. Over the last few months I've moved most of those projects to go-task (usually just called Task) and a Taskfile.yml instead.
Wails pushed me to look closer
A big part of this came from recent work with Wails, the Go framework I've been using for desktop apps like katdown and goWailsSystemUtil (more in Apps I've Made). The new version, Wails v3, already uses Task for its build system. A new v3 project comes with a Taskfile.yml plus per-platform Taskfiles under build/, and the wails3 CLI runs those tasks to build, package and run your app in dev mode.
So I was already reading, editing and running Taskfiles every time I touched a Wails project. Keeping a Makefile beside them to do the same job felt silly, and having two different task runners in my head for Go projects felt worse. That got me to stop treating Task as "that thing Wails uses" and actually learn it.
What I found
Most of my Makefiles were really just lists of .PHONY commands. For that kind of use Task is simply nicer: it's YAML instead of tab-sensitive Make syntax, task --list shows every documented task without a hand-rolled help target, it can skip work based on file checksums, and the same Taskfile runs on Windows without needing a POSIX shell. I still use Make where it's the right tool, which is why this post says "mostly."
While I was figuring it out, I put together the guide below. It covers when to switch and when not to, the semantic differences that will bite you if you migrate on autopilot, a Makefile-to-Taskfile cookbook, and a complete example Taskfile. It was checked against Task v3.54.0 (released 2026-10-01, schema version 3) and GNU Make 4.4.1 (released 26 Feb 2023, still the latest GNU Make release as of November 2026).
If you're curious how I pick config formats like YAML in general, I wrote about that in How to choose a config file format.
TL;DR
- Switch to Task if your Makefile is mostly phony targets. Every Task task is a named action, so you no longer need
.PHONY. You gettask --list,desc/summary, checksum-basedsources/generates,status,preconditions,prompt,requires, watch mode, dotenv loading and an embedded Go shell interpreter, all from one binary on Linux, macOS and Windows. - Keep Make for real file-dependency graphs. Task has no pattern rules (
%.o: %.c), implicit rules or automatic variables ($@,$<,$^). It is not a drop-in replacement for C/C++-style incremental builds or for anything that depends on Make being preinstalled. - Learn three semantic changes before migrating:
depsrun concurrently and in no guaranteed order (usetask:calls incmdsfor ordering); eachcmdsline runs in its own shell (no carried-overcd/export); andvarsare template values, not environment variables (useenvto export).
Make vs. Task at a glance
| Area | GNU Make (4.4.1) | Task (v3.54.0, schema v3) | Practical implication |
|---|---|---|---|
| File format | Makefile DSL; recipe lines must start with a tab (or .RECIPEPREFIX) | YAML (Taskfile.yml), version: '3' required | No tab errors. Editors can validate Taskfiles against the official JSON schema |
| Core model | File-based: targets are files, rules say how to rebuild them | Task-based: every entry is a named action | .PHONY is unnecessary in Task |
| Up-to-date logic | "A target is out of date if it does not exist or if it is older than any of the prerequisites" (mtime) | sources/generates with method: checksum (default), timestamp, or none, plus status commands | Task ignores mtime-only changes such as git checkout or touch. Fingerprints are stored in .task/ |
| Variables | =, :=/::=, :::=, ?=, $(shell β¦), functions | vars with Go text/template ({{.VAR}}), sh: dynamic vars, slim-sprig functions | Different syntax and precedence rules; read the precedence table below |
| Dependencies | Prerequisites, serial unless -j | deps run concurrently by default; task: in cmds is sequential | Ordering bugs are the most common migration mistake |
| Parallelism | -j N, -l, jobserver, -O output sync | Concurrent deps, --parallel, --concurrency, failfast, output: prefixed/group | Task is parallel by default; Make is serial by default |
| Includes | include file.mk (textual) | includes: with namespaces, dir, optional, flatten, internal, excludes, aliases, remote URLs | Task gives you real namespaces (docs:build) |
| Env / dotenv | Exported make variables; no native dotenv | env:, dotenv: (first file wins), process env wins by default | Native .env support |
| Cross-platform | Needs a POSIX shell and coreutils | Embedded mvdan/sh interpreter; built-in core utils on Windows | The same Taskfile can run in PowerShell/cmd environments |
| Discoverability | Hand-rolled make help | task --list, --list-all, --summary, --json | Self-documenting without extra code |
| Dry run | -n/--just-print/--dry-run | --dry/-n | Both may still execute some things (see Caveats) |
| Errors | Stop on first error; - prefix, -i, -k | Stop on failure; ignore_error, defer, failfast, per-command timeout, documented exit codes | Task has built-in cleanup via defer |
| Availability | Preinstalled on most Unix systems | Single extra binary (apt, snap, brew, winget, npm, go install, script, go tool) | Task adds a bootstrap step |
Details: The Differences That Matter When Switching
1. Syntax: Makefile vs. YAML
The GNU Make manual says recipe lines must start with a tab: "The recipe lines start with a tab character (or the first character in the value of the .RECIPEPREFIX variableβ¦)". Task replaces this with YAML. A task is a map entry under tasks: with a cmds list:
# Makefile
.PHONY: build
build:
go build -o bin/app .# Taskfile.yml
version: '3'
tasks:
build:
cmds:
- go build -o bin/app .Task also accepts shorthand when you only need commands: build: go build -v -o ./app{{exeExt}} .. YAML has its own pitfalls (quoting, colons, {{ at the start of a value; see Gotchas). For editor validation and completion, add # yaml-language-server: $schema=https://taskfile.dev/schema.json at the top of the file.
2. File-based vs. task-based model
Make is built around files. A rule describes "when the targets are out of date, and how to update them". Tasks that aren't files have to be declared .PHONY, otherwise a stray file called clean stops make clean from running. Task works the other way round. Every task is an action, and file awareness is opt-in through sources/generates. The trade-off is that Make can derive a build graph from file patterns, while in Task you write the graph out explicitly.
3. Up-to-date tracking: timestamps vs. checksums and status
- Make: compares last-modification times of target vs. prerequisites.
- Task: declare
sources(inputs) and optionallygenerates(outputs). "The default method,checksum, compares the content of source files with a saved fingerprint. It checks that declared outputs exist; it does not compare their contents." You can switch tomethod: timestampormethod: none, per task or at the root. status: shell checks that all must exit 0 for the task to be skipped. Use them for things that aren't local files, such as a Docker image tag, an installed tool, or a remote artifact.- Combined: when you use
sources,generatesandstatustogether, "every configured check must pass for Task to consider the work up to date." Incmds/status,{{.CHECKSUM}}(checksum method) or{{.TIMESTAMP}}(timestamp method) describe the sources. - Per-variant fingerprints: Task stores "one source fingerprint per task label". If a variable changes the output (e.g.
MODE=debug|release), template it intolabel:andgenerates:. - CLI:
task --status buildexits non-zero if not up to date;task --force build(-f) reruns regardless. - Storage: fingerprints live in
.task/; add it to.gitignore. Since v3.52.0 you can relocate them with--temp-dir/TASK_TEMP_DIR, anduse_gitignore: truefilters globs through.gitignore.
What this means in practice: checksum mode doesn't rebuild after touch or a branch switch that leaves content unchanged, which Make would. On the other hand, it doesn't notice a hand-edited output, because it only checks that outputs exist.
4. Variables and templating
Make has recursively expanded (=), simply expanded (:=/::=), immediately expanded (:::=) and conditional (?=) variables, $(shell β¦), and text functions. Command-line make VAR=x overrides makefile assignments.
Task uses Go text/template, and most functions come from the slim-sprig library:
vars:
APP: myapp # literal
COMMIT:
sh: git rev-parse --short HEAD # dynamic (β $(shell ...))
FILES: [a.go, b.go] # arrays/maps are supported
tasks:
hello:
cmds:
- echo "{{.APP}} @ {{.COMMIT}}"
- echo '{{if eq OS "windows"}}win{{else}}unix{{end}}'
- echo '{{.NAME | default "World"}}'Key semantics from the official docs:
- Precedence, applied first to last, later wins: process environment β special variables (
TASK,ROOT_DIR,CLI_ARGSβ¦) β Taskfileenv:β globalvars:β includevars:β the included Taskfile's ownvars:β call variables (task foo BAR=1) β the task's ownvars:. - Biggest Make-user trap: "A constant in a task's own
vars:overrides the command line." Put defaults in globalvars:or useNAME: '{{.NAME | default "World"}}'. sh:variables resolve in declaration order and are "cached for the run, keyed on the command string".varsare not exported tocmds. Use templates, or put the value inenv:.- Templates render text. To pass arrays/maps without flattening, use
ref:. secret: true(v3.52.0+) masks a var in Task's logs. It does not mask what the command itself prints, and it doesn't work onenv:.
5. Dependencies, ordering and parallelism
| Need | Make | Task |
|---|---|---|
| Prerequisites before a step | build: lint test | deps: [lint, test] (concurrent, any order) |
| Strict sequence | Prerequisite order under -j1, or recursive $(MAKE) | cmds: [{task: lint}, {task: test}, go build ./...] |
| Parallel | make -j8 | Default for deps; task --parallel a b (-p) for CLI tasks |
| Throttle | -j N, -l load | --concurrency N (-C), default 0 = unlimited |
| Stop siblings on failure | (default stop; -k keeps going) | failfast: true / --failfast (by default, other deps finish) |
| Run shared dep once | Make naturally builds each target once | run: once or run: when_changed (default always) |
| Readable parallel output | -O/--output-sync | output: prefixed or output: group |
The docs are explicit: "The order of entries in deps does not set their execution order," and a --concurrency 1 limit "does not establish an order between dependencies". Make's equivalent is -j, and its manual notes that -l limits jobs by load average.
6. Includes and namespacing
Make's include pastes text into one global namespace. Task's includes: creates namespaced tasks:
includes:
docs: ./docs/Taskfile.yml # β task docs:build
backend:
taskfile: ./backend
dir: ./backend # run included tasks there
optional: true # don't fail if missing
aliases: [be]
excludes: ['debug:*'] # namespace exclusion (v3.53.1+)
vars: { SERVICE_NAME: backend }
common:
taskfile: ./common.yml
flatten: true # no namespace prefix
helpers:
taskfile: ./internal.yml
internal: true # hidden, not callable from CLIIncluded Taskfiles "must use the same schema version". To call a root task from inside an include, use a leading colon: task: :build. Remote Taskfiles (HTTP/Git) became generally available in v3.53.1 (2026-08-18); the release notes say "This has been an experimental feature for 3 years, but is now enabled by default." They run code from wherever they are fetched, so pin a checksum: for anything you don't control.
7. Environment variables and dotenv
dotenv: ['.env.local', '.env'] # first file wins; missing files skipped
env:
CGO_ENABLED: '0'
tasks:
greet:
env: { GREETING: Hello }
cmds: [ 'echo "$GREETING"' ]Rules worth memorizing:
- By default, "a variable already present in the process environment takes precedence over Taskfile
envvalues". The opt-in Env Precedence experiment changes this. - Task-level
envbeats task-leveldotenv. Root-level dotenv loads before call variables exist, so templated paths like.env.{{.ENV}}belong on the task. dotenv"is not supported inside included Taskfiles". Load shared values in the entrypoint.- Make has no native dotenv; people usually use
include .env+export, which breaks on quoting.
8. Cross-platform behavior and the built-in shell
Task "uses mvdan/sh, a native Go sh interpreter", so sh/bash-style commands work "even in environments where sh or bash are usually not available (like Windows)". Since v3.45.3 (2025-09-15), whose changelog says "Task now includes built-in core utilities to greatly improve compatibility on Windows," Task ships Go implementations of common core utilities. They are enabled by default on Windows and controlled by TASK_CORE_UTILS: base64, cat, chmod, cp, find, gzip, ls, mkdir, mktemp, mv, rm, shasum, tar, touch, xargs. Task's creator Andrey Nering wrote in the "Announcing Built-in Core Utilities for Windows" blog post that when he started Task in 2017, "one of my biggest goals was to build a task runner that would work well on all major platforms, including Windows⦠it caught my attention how much of a pain it was to get a working version of Make on Windows."
Limits:
- Task "does not start your interactive shell or load that shell's startup configuration."
- "Each
cmdsentry gets a separate shell context. Acdorexportin one entry does not carry over to the next." Usedir:andenv:, or one multiline command. - "A task is only cross-platform if its commands are."
go,docker,golangci-lintetc. still need to be installed. - Use
platforms: [windows],[linux, darwin],[windows/amd64]on tasks or individual commands (GOOS/GOARCH names). Non-matching commands are skipped without error. Template helpers:{{OS}},{{ARCH}},{{exeExt}}. set: [pipefail]andshopt: [globstar]are available at the root, task or command level. The interpreter supports only a subset of options.
9. .PHONY vs. default task behavior; the default task
In Make, a phony target "is one that is not really the name of a file", and the manual recommends .PHONY both to avoid file-name clashes and for performance (it skips implicit-rule search). The default goal is the first target, or .DEFAULT_GOAL. In Task, every task behaves as phony unless you add sources/generates/status. A task named default runs when you type plain task.
10. Listing, descriptions and summaries
desc:β shown bytask --list(-l).--list-all(-a) includes undocumented tasks.internal: truetasks are always hidden.summary:βtask --summary releaseprints long help, dependencies and commands without running anything.--jsonwith--list/--list-allemits machine-readable task metadata, includingup_to_dateand source location.- Make has none of this built in. The common
make helpgrep idiom is a convention you have to maintain yourself.
11. Preconditions, conditional execution, validation and prompts
| Need | Task key | Behavior |
|---|---|---|
| Skip optional work | if: (task or command) | Non-zero exit β skipped, not failed |
| Fail fast on unmet requirement | preconditions: (sh + msg) | Fails the task and its callers; dependencies have already run |
| Already done? | status: / sources | Skip as up to date |
| Require variables | requires: { vars: [...] } with optional enum | Missing β error (exit code 206) |
| Ask for missing vars | task --interactive | Prompts for requires vars |
| Confirm dangerous action | prompt: | Declining β exit code 205; --yes/-y auto-accepts; fails in CI without a TTY unless auto-accepted |
The docs warn that "--force bypasses preconditions as well as up-to-date checks." In Make you'd write these checks as shell test lines or $(error β¦) conditionals.
12. Watch mode
task --watch build (-w) reruns a task when its sources change. watch: true on a task makes plain task build start watching, but only when it is called from the CLI. The interval can be set with --interval or the root interval: key. Make has no watch mode; you'd need entr, watchexec or similar. Documentation conflict: the Watch guide and schema reference give a default interval of 100 ms, while the CLI reference says "default: 5s". Set the interval explicitly if it matters to you.
13. CLI arguments and variables
| Input | Example | Read with |
|---|---|---|
| Variable | task greet NAME=Bob | {{.NAME}} |
| Pass-through args | task test -- -run TestFoo -v | {{.CLI_ARGS}} (string) / {{.CLI_ARGS_LIST}} (list) |
| Part of task name (wildcard) | task start:api | {{index .MATCH 0}} |
Several names run in order (task lint test). Make's equivalents are make VAR=x and goal lists. Make has no standard -- passthrough.
14. Error handling
Make stops on the first failing recipe line. A leading - ignores the error for that line, -i ignores all errors, and -k/--keep-going continues with unrelated targets. Task stops a task on a failed command and offers:
ignore_error: trueon a command or task;defer:cleanup (string or{task: cleanup}), which runs in reverse order whether the task succeeded or failed, but only if Task reached thedeferentry.{{.EXIT_CODE}}is available inside deferred commands;timeout:per command (v3.53.1+, Go duration syntax, exit code 124);- documented exit codes: e.g. 100 no Taskfile, 107 no schema version, 200 task not found, 201 command error, 205 cancelled, 206 missing required vars. v3.54.0 fixed user-cancelled tasks wrongly returning 201 instead of 205;
- automatic GitHub Actions error annotations when
GITHUB_ACTIONS=true.
15. Dry run
- Make:
-n/--just-print/--dry-runprints recipes without running them, but "some recipes are still executed", namely lines with+or$(MAKE), and recipes that remake included makefiles. - Task:
task --dry build(-n) prints the commands. However, "A variable declared withshcan therefore execute its shell command during a dry run." Keepsh:vars side-effect-free.
16. Performance
Neither project publishes head-to-head benchmarks against the other, so treat any blog claim of "Task is faster/slower than Make" with caution. Some concrete data points:
- Task's changelog entry for v3.53.1 (2026-08-18) states: "Fingerprinting is up to 86% faster and make up to 70% fewer memory allocations on the more advanced scenarios." In the same release, PR #2925 made source hashing reuse a single buffer, "reducing memory allocations by ~98% and wall-clock time by ~7%."
- Checksum mode has to read and hash every source file, while Make's mtime check is a cheap
stat. On very large trees Make's approach is inherently cheaper, and Task'smethod: timestampor narrower globs close the gap. - For typical Go/devops task runners, process startup and the commands themselves (compilers, Docker) dominate, and runner overhead doesn't matter.
17. Installation
| Platform | Command | Maintained by |
|---|---|---|
| Ubuntu/Debian (apt) | Cloudsmith setup.deb.sh (see below) then sudo apt install task | Task team (Cloudsmith repo) |
| Any Linux (snap) | sudo snap install task --classic (needs classic confinement) | Task team |
| Fedora/RHEL (dnf) | Cloudsmith setup.rpm.sh then dnf install task | Task team |
| Alpine | Cloudsmith setup.alpine.sh then apk add task | Task team |
| macOS | brew install go-task/tap/go-task or brew install go-task | Task team / Homebrew |
| Windows | winget install Task.Task (also Scoop, Chocolatey: community) | Task team / community |
| npm | npm install -g @go-task/cli | Task team |
| Go toolchain | go install github.com/go-task/task/v3/cmd/task@latest | β |
| Go project-pinned | go get -tool github.com/go-task/task/v3/cmd/task@latest then go tool task β¦ | β |
| Script / CI | Official install.sh script (see below) | Task team |
| GitHub Actions | uses: go-task/setup-task@v2 | Task team |
| Others | pip go-task-bin, mise, pacman go-task, Nix, MacPorts, FreeBSD pkg install task | Community (may lag) |
The two installs that pipe a script into a shell:
# Ubuntu/Debian apt repo
curl -1sLf 'https://dl.cloudsmith.io/public/task/task/setup.deb.sh' | sudo -E bash
sudo apt install task
# Install script (defaults to ./bin; append a tag like v3.54.0 to pin)
sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b ~/.local/binNote: some older third-party Ubuntu guides say "Taskfile does not support apt". That is outdated, because the official docs now list an apt repository. On some distros the package/binary is named go-task. Set TASK_EXE if your binary isn't called task. Shell completions: eval "$(task --completion bash)" (also zsh, fish, powershell, nu). Verify with task --version.
18. Ecosystem and portability trade-offs
- For Task: one static binary, the same behavior on all three OSes, JSON schema for editors,
go toolpinning for Go repos, remote/shared Taskfiles, an active release cadence (v3.47 to v3.54 during 2026 alone), and an official GitHub Action. - Against Task: it is an extra dependency in every dev container and CI image. It is a single-project tool with schema evolution: go-task/task issue #3055, the "V4 Roadmap" epic opened by @vmaerten on Sep 26, 2026, lists scoped variables, dropping preconditions in favor of
if, separating env and vars, and renaming ignore_error to continue-on-error as changes under consideration, though it cautions that "Nothing listed here is final or guaranteed to be part of v4". Its Go template syntax is unfamiliar to non-Go developers. - For Make: ubiquitous, standardized (POSIX core), 30+ years of documentation, understood by every distro packager, autotools and IDE, and highly capable for file graphs.
- Against Make: tab sensitivity, two variable flavors, poor Windows story, no listing/dotenv/watch, and differences between GNU and BSD make.
19. When Make is still the better choice
- True incremental file builds (C/C++, LaTeX, data pipelines) that rely on pattern rules, implicit rules, automatic variables or generated dependency files (
-MMD). - Zero-install requirements: tarball builds (
./configure && make), distro packaging, minimal containers, environments where you can't add binaries. - Recursive builds that need a shared jobserver for a global
-jlimit across sub-makes. - Toolchains that expect Make (autotools, kernel-style Kbuild, many language ecosystems' conventions).
- Huge trees where mtime checks are much cheaper than hashing.
A pragmatic hybrid works well: keep a Makefile for the compiled-artifact graph and let Task orchestrate the developer workflow around it (task build β make -j).
Migration Cookbook: Makefile β Taskfile.yml
| Pattern | Makefile | Taskfile.yml |
|---|---|---|
| Phony target | .PHONY: testtest:(tab)go test ./... | test: cmds: [go test ./...] |
| Variable | BIN := bin/app | vars: { BIN: bin/app } β {{.BIN}} |
| Overridable default | GOFLAGS ?= -v | Define it in root vars:, or use the default function (see Variables above) |
| Shell-computed | VERSION := $(shell git describe --tags) | VERSION: { sh: git describe --tags } |
| Exported env | export CGO_ENABLED=0 | env: { CGO_ENABLED: '0' } |
| Prereqs | all: lint test build | all: { deps: [lint, test, build] } (parallel!) or cmds: [{task: lint}, β¦] |
| Default goal | first target / .DEFAULT_GOAL := help | task named default |
| Ignore error | -rm -f x | - cmd: rm x ignore_error: true (or rm -f x) |
| Silence echo | @echo hi | silent: true (root, task, or command) |
| Run in subdir | cd web && npm ci or $(MAKE) -C web | dir: web |
| Include | include common.mk | includes: { common: ./common.yml } |
| File target | bin/app: $(wildcard *.go) | sources: ['**/*.go'], generates: [bin/app] |
| Pattern rule | %.min.js: %.js | No direct equivalent; for: sources loop or wildcard task |
| Parallel run | make -j4 a b | task -p -C 4 a b |
| Arg passthrough | make test ARGS="-run X" | task test -- -run X β {{.CLI_ARGS}} |
| Help | grep-based help target | desc: + task --list |
Worked examples (Go-flavored)
Build, test, lint, clean
# Makefile
BIN := bin/app
VERSION := $(shell git describe --tags --always --dirty)
LDFLAGS := -s -w -X main.version=$(VERSION)
.PHONY: build test lint clean
build: $(BIN)
$(BIN): $(shell find . -name '*.go') go.mod go.sum
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o $@ ./cmd/app
test:
go test -race ./...
lint:
golangci-lint run
clean:
-rm -rf bin# Taskfile.yml
version: '3'
vars:
BIN: bin/app{{exeExt}}
VERSION:
sh: git describe --tags --always --dirty
LDFLAGS: -s -w -X main.version={{.VERSION}}
tasks:
build:
desc: Build the binary
env: { CGO_ENABLED: '0' }
sources: ['**/*.go', go.mod, go.sum, {exclude: '**/*_test.go'}]
generates: ['{{.BIN}}']
cmds:
- go build -ldflags "{{.LDFLAGS}}" -o {{.BIN}} ./cmd/app
test:
desc: Run tests (pass extra flags after --)
cmds:
- go test -race ./... {{.CLI_ARGS}}
lint:
desc: Run golangci-lint
preconditions:
- sh: command -v golangci-lint
msg: Install golangci-lint first
cmds:
- golangci-lint run
clean:
desc: Remove build output
cmds:
- rm -rf binDocker image only when inputs change (the pattern from the official docs):
image:
desc: Build image tagged with source checksum
sources: [Dockerfile, go.mod, go.sum, '**/*.go']
status:
- docker image inspect example/app:{{.CHECKSUM}} > /dev/null 2>&1
cmds:
- docker build -t example/app:{{.CHECKSUM}} .Pattern rule β loop. Make's %.pb.go: %.proto has no direct Task equivalent. The closest is a loop over sources:
proto:
sources: ['proto/*.proto']
generates: ['proto/*.pb.go']
cmds:
- for: sources
cmd: protoc --go_out=. --go_opt=paths=source_relative {{.ITEM}}Note the difference: this reruns all files when any changes. Make would rebuild only the stale ones.
Parameterized targets β wildcard task or variables
build:*: # task build:linux, task build:windows
vars: { GOOS: '{{index .MATCH 0}}' }
env: { GOOS: '{{.GOOS}}' }
cmds:
- go build -o bin/app-{{.GOOS}} ./cmd/appParallel deps with ordering where it matters
ci:
desc: Lint+test in parallel, then build
deps: [lint, test] # concurrent
cmds:
- task: build # runs after both deps succeedHow to Read a Taskfile
The style guide's recommended top-level order is version, includes, optional settings (output, silent, method, runβ¦), vars, env/dotenv, tasks.
| Key | Where | What it means |
|---|---|---|
version | root (required) | Schema version: '3' (or a semver). Missing β exit 107 |
includes | root | Other Taskfiles, namespaced (ns:task) |
output | root | interleaved (default), group, prefixed |
method | root/task | checksum (default), timestamp, none |
run | root/task | always (default), once, when_changed |
silent | root/task/cmd | Don't echo commands (output still shows) |
vars | root/include/task/call | Template variables: literal, sh:, ref:, map: |
env | root/task | Environment exported to commands |
dotenv | root/task | .env files; first file wins; not in includes |
set / shopt | root/task/cmd | Shell options (pipefail, globstar) |
tasks | root | Map of task name β task definition (or shorthand string/list) |
desc / summary | task | Short help for --list; long help for --summary |
aliases / label / internal | task | Alternate names; log label; hide from CLI/listing |
cmds | task | Ordered commands; each item may be a string, {cmd: β¦}, {task: β¦}, {defer: β¦}, {for: β¦} |
deps | task | Tasks run concurrently before cmds |
sources / generates | task | Inputs/outputs for up-to-date checks and watch |
status | task | Shell checks; all pass β skip |
preconditions | task | Shell checks; any fail β error with msg |
if | task/cmd | Shell/template condition; false β skip silently |
requires | task | Required vars, optional enum |
prompt | task | Confirmation question(s) |
dir | task/include | Working directory (created if missing) |
platforms | task/cmd | Restrict to OS/arch |
ignore_error, timeout, failfast, watch | task/cmd | Error, time and run controls |
How templating works: Before running, Task renders every templated string with Go text/template. {{.VAR}} reads a variable (note the leading dot). Functions are called bare ({{OS}}, {{exeExt}}) or piped ({{.X | default "y"}}, {{splitList "," .NUMBERS | uniq | join ", "}}). Control flow uses {{if}}β¦{{else}}β¦{{end}} and {{range}}. Special variables include .TASK, .ROOT_DIR, .TASKFILE, .TASK_DIR, .USER_WORKING_DIR, .CLI_ARGS, .CLI_FORCE, .CHECKSUM/.TIMESTAMP, .ITEM/.KEY in loops, .MATCH for wildcards, and .EXIT_CODE in defer. Templates produce text that is then handed to the shell, so $VAR (shell/env) and {{.VAR}} (Task) are different things and can both appear in the same command.
Building a Taskfile from Scratch (Step by Step)
- Install and verify: pick a method from the table above and run
task --version. - Scaffold:
task --initin the repo root creates a starterTaskfile.ymlwith adefaulttask. - Add the schema comment for editor validation.
- Port phony targets first: one task per Make target,
cmdscopied over. Replace$(VAR)with{{.VAR}},$@/$<with explicit names, and@withsilent. - Move shared values into root
vars/env, and secrets into.envloaded viadotenv. - Decide ordering: use
depswhere concurrency is safe, andtask:calls incmdswhere order matters. - Add
descto every public task. Mark helpersinternal: true. - Add incrementality:
sources/generatesfor builds,statusfor external artifacts. - Add guardrails:
preconditionsfor required tools,requiresfor inputs,promptfor destructive tasks. - Validate:
task --list,task --dry <task>,task --summary <task>, then real runs. Usetask --status buildin CI to assert freshness. - Commit
Taskfile.yml, add.task/and.env*(except examples) to.gitignore, and install Task in CI (go-task/setup-task@v2or the install script with a pinned version).
Complete example: Taskfile.yml for a Go service
# yaml-language-server: $schema=https://taskfile.dev/schema.json
# Taskfile for a Go service. Requires Task v3.x (verified against v3.54.0).
version: '3'
# --- Shared Taskfiles ------------------------------------------------------
includes:
# Optional per-developer overrides; absent file is fine.
local:
taskfile: ./Taskfile.local.yml
optional: true
# --- Global settings -------------------------------------------------------
output: prefixed # label each line with its task (useful with parallel deps)
set: [pipefail] # fail pipelines if any stage fails
# --- Variables (template values, NOT exported to the shell) -----------------
vars:
APP: myservice
BIN: 'bin/{{.APP}}{{exeExt}}' # .exe suffix on Windows only
IMAGE: 'ghcr.io/acme/{{.APP}}'
VERSION: # dynamic var, computed once per run
sh: git describe --tags --always --dirty
LDFLAGS: '-s -w -X main.version={{.VERSION}}'
PKGS: ./...
# --- Environment (exported to every command) --------------------------------
dotenv: ['.env.local', '.env'] # first file wins; missing files skipped
env:
CGO_ENABLED: '0'
GOFLAGS: -mod=readonly
tasks:
default: # `task` with no args
desc: Show available tasks
silent: true
cmds:
- task --list
tools:
desc: Install pinned dev tools
status: # skip if already installed
- command -v golangci-lint
cmds:
- go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
fmt:
desc: Format code
cmds:
- go fmt {{.PKGS}}
lint:
desc: Static analysis
deps: [tools]
cmds:
- go vet {{.PKGS}}
- golangci-lint run
test:
desc: Run tests; pass extra flags after `--` (e.g. task test -- -run TestX -v)
sources: ['**/*.go', go.mod, go.sum] # skip when nothing changed
cmds:
- go test -race {{.PKGS}} {{.CLI_ARGS}}
env:
CGO_ENABLED: '1' # -race needs cgo; overrides root env
build:
desc: Build {{.APP}} for the current platform
sources:
- '**/*.go'
- go.mod
- go.sum
- exclude: '**/*_test.go'
generates: ['{{.BIN}}']
cmds:
- go build -ldflags "{{.LDFLAGS}}" -o {{.BIN}} ./cmd/{{.APP}}
build:*:
desc: Cross-compile, e.g. task build:linux or task build:windows
vars:
TARGET_OS: '{{index .MATCH 0}}'
label: 'build-{{.TARGET_OS}}' # separate fingerprint per OS
env:
GOOS: '{{.TARGET_OS}}'
sources: ['**/*.go', go.mod, go.sum]
generates: ['bin/{{.APP}}-{{.TARGET_OS}}']
cmds:
- go build -ldflags "{{.LDFLAGS}}" -o bin/{{.APP}}-{{.TARGET_OS}} ./cmd/{{.APP}}
ci:
desc: Lint and test in parallel, then build
deps: [lint, test] # concurrent
failfast: true # cancel the other dep if one fails
cmds:
- task: build # sequential, after deps
run:
desc: Build and run locally
cmds:
- task: build
- ./{{.BIN}} {{.CLI_ARGS}}
dev:
desc: Rebuild on every change (watch mode)
watch: true
sources: ['**/*.go']
cmds:
- task: build
image:
desc: Build Docker image only if inputs changed
preconditions:
- sh: command -v docker
msg: Docker is required for this task
sources: [Dockerfile, go.mod, go.sum, '**/*.go']
status:
- docker image inspect {{.IMAGE}}:{{.CHECKSUM}} > /dev/null 2>&1
cmds:
- docker build -t {{.IMAGE}}:{{.CHECKSUM}} -t {{.IMAGE}}:latest .
release:
desc: Tag and push a release
requires:
vars:
- name: CHANNEL
enum: [staging, production]
prompt: 'Push {{.VERSION}} to {{.CHANNEL}}?'
deps: [ci]
cmds:
- echo "Releasing {{.VERSION}} to {{.CHANNEL}}"
integration:
desc: Integration tests against docker compose; always tear down
cmds:
- docker compose up -d
- defer: docker compose down # runs even if tests fail
- go test -tags=integration {{.PKGS}}
clean:
desc: Remove build artifacts and Task fingerprints
cmds:
- rm -rf bin .task # rm is built in on Windows tooTry it: task (lists tasks), task build, task build again ("up to date"), task test -- -run TestHealth -v, task -p lint test, task build:linux, task release CHANNEL=staging, task --dry ci.
Troubleshooting and Gotchas
- "My deps ran in the wrong order."
depsare concurrent with no ordering. Usecmds: [{task: a}, {task: b}]. cd fooin one command, wrong directory in the next. Eachcmdsentry is a new shell. Usedir:or a single multiline command (|).$MYVARis empty in a command. It's invars, notenv. Use{{.MYVAR}}or add it toenv:.task build VERSION=2is ignored. A constant in the task's ownvars:beats the CLI. Move the default to rootvarsor use| default.- Env value from Taskfile ignored. The process environment wins by default.
unsetit or check what your shell or CI exports. - YAML parse errors with templates. Quote any value that starts with
{{or contains:, e.g.BIN: '{{.APP}}'. - Task never rebuilds / always rebuilds. Check that
sourcesglobs are relative to the task'sdir, thatexcludecomes after the positive glob, and that the.task/directory isn't shared across checkouts. Uselabelper variant. Runtask --statusto inspect. - Edited the binary but no rebuild. By design, checksum mode only checks that
generatesexist. - Dry run had side effects.
sh:vars execute during--dry. promptfails in CI. There is no TTY. Usetask --yes.- Preconditions "didn't stop" a dep. Dependencies run before the parent's preconditions are evaluated. Put the check on the dependency.
- Windows command not found. Only the listed core utils are built in. Install real tools or guard commands with
platforms:. dotenvin an included Taskfile does nothing. It's unsupported there. Load it in the root Taskfile.- Remote include trust prompt / exit 104. Review the file and pin its
checksum:.
Recommendations
- Migrate if more than about 80% of your Makefile is
.PHONYtargets, your team includes Windows developers, or you want self-documenting workflows. Start with a parallelTaskfile.ymlthat mirrors existing targets, run both in CI for a sprint, then delete the Makefile, or leave a thin shim (%: ; @task $@) for muscle memory. - Pin Task's version in CI (install script tag,
setup-taskversioninput, orgo toolingo.mod). The project ships frequently, and a v4 is on the roadmap. - Don't migrate compiled-artifact graphs that depend on pattern rules. Wrap them instead (
task buildβmake -j). - Adopt the guardrails early (
desc,requires,preconditions,prompt,defer). They are where Task adds the most over Make.
Caveats
- Feature details here reflect Task v3.54.0 docs (Nov 2026). Some features are recent (remote Taskfiles GA in v3.53.1,
secret,use_gitignore,--temp-dirin v3.52.0, per-commandtimeoutin v3.53.1), so older installed versions may lack them. - The watch-interval default is inconsistent across Task's own docs (100 ms vs. 5 s).
- No independent, rigorous Make-vs-Task benchmark was found. Performance statements are qualitative except for Task's own changelog figures.

