Dev In The Mountain Header
A Developer In The mountains having fun

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 get task --list, desc/summary, checksum-based sources/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: deps run concurrently and in no guaranteed order (use task: calls in cmds for ordering); each cmds line runs in its own shell (no carried-over cd/export); and vars are template values, not environment variables (use env to export).

Make vs. Task at a glance

AreaGNU Make (4.4.1)Task (v3.54.0, schema v3)Practical implication
File formatMakefile DSL; recipe lines must start with a tab (or .RECIPEPREFIX)YAML (Taskfile.yml), version: '3' requiredNo tab errors. Editors can validate Taskfiles against the official JSON schema
Core modelFile-based: targets are files, rules say how to rebuild themTask-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 commandsTask ignores mtime-only changes such as git checkout or touch. Fingerprints are stored in .task/
Variables=, :=/::=, :::=, ?=, $(shell …), functionsvars with Go text/template ({{.VAR}}), sh: dynamic vars, slim-sprig functionsDifferent syntax and precedence rules; read the precedence table below
DependenciesPrerequisites, serial unless -jdeps run concurrently by default; task: in cmds is sequentialOrdering bugs are the most common migration mistake
Parallelism-j N, -l, jobserver, -O output syncConcurrent deps, --parallel, --concurrency, failfast, output: prefixed/groupTask is parallel by default; Make is serial by default
Includesinclude file.mk (textual)includes: with namespaces, dir, optional, flatten, internal, excludes, aliases, remote URLsTask gives you real namespaces (docs:build)
Env / dotenvExported make variables; no native dotenvenv:, dotenv: (first file wins), process env wins by defaultNative .env support
Cross-platformNeeds a POSIX shell and coreutilsEmbedded mvdan/sh interpreter; built-in core utils on WindowsThe same Taskfile can run in PowerShell/cmd environments
DiscoverabilityHand-rolled make helptask --list, --list-all, --summary, --jsonSelf-documenting without extra code
Dry run-n/--just-print/--dry-run--dry/-nBoth may still execute some things (see Caveats)
ErrorsStop on first error; - prefix, -i, -kStop on failure; ignore_error, defer, failfast, per-command timeout, documented exit codesTask has built-in cleanup via defer
AvailabilityPreinstalled on most Unix systemsSingle 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 .
yaml
# 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 optionally generates (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 to method: timestamp or method: 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, generates and status together, "every configured check must pass for Task to consider the work up to date." In cmds/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 into label: and generates:.
  • CLI: task --status build exits 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, and use_gitignore: true filters 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:

yaml
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…) β†’ Taskfile env: β†’ global vars: β†’ include vars: β†’ the included Taskfile's own vars: β†’ call variables (task foo BAR=1) β†’ the task's own vars:.
  • Biggest Make-user trap: "A constant in a task's own vars: overrides the command line." Put defaults in global vars: or use NAME: '{{.NAME | default "World"}}'.
  • sh: variables resolve in declaration order and are "cached for the run, keyed on the command string".
  • vars are not exported to cmds. Use templates, or put the value in env:.
  • 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 on env:.

5. Dependencies, ordering and parallelism

NeedMakeTask
Prerequisites before a stepbuild: lint testdeps: [lint, test] (concurrent, any order)
Strict sequencePrerequisite order under -j1, or recursive $(MAKE)cmds: [{task: lint}, {task: test}, go build ./...]
Parallelmake -j8Default 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 onceMake naturally builds each target oncerun: once or run: when_changed (default always)
Readable parallel output-O/--output-syncoutput: 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:

yaml
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 CLI

Included 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

yaml
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 env values". The opt-in Env Precedence experiment changes this.
  • Task-level env beats task-level dotenv. 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 cmds entry gets a separate shell context. A cd or export in one entry does not carry over to the next." Use dir: and env:, or one multiline command.
  • "A task is only cross-platform if its commands are." go, docker, golangci-lint etc. 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] and shopt: [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 by task --list (-l). --list-all (-a) includes undocumented tasks. internal: true tasks are always hidden.
  • summary: β†’ task --summary release prints long help, dependencies and commands without running anything.
  • --json with --list/--list-all emits machine-readable task metadata, including up_to_date and source location.
  • Make has none of this built in. The common make help grep idiom is a convention you have to maintain yourself.

11. Preconditions, conditional execution, validation and prompts

NeedTask keyBehavior
Skip optional workif: (task or command)Non-zero exit β†’ skipped, not failed
Fail fast on unmet requirementpreconditions: (sh + msg)Fails the task and its callers; dependencies have already run
Already done?status: / sourcesSkip as up to date
Require variablesrequires: { vars: [...] } with optional enumMissing β†’ error (exit code 206)
Ask for missing varstask --interactivePrompts for requires vars
Confirm dangerous actionprompt: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

InputExampleRead with
Variabletask greet NAME=Bob{{.NAME}}
Pass-through argstask 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: true on 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 the defer entry. {{.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-run prints 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 with sh can therefore execute its shell command during a dry run." Keep sh: 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's method: timestamp or 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

PlatformCommandMaintained by
Ubuntu/Debian (apt)Cloudsmith setup.deb.sh (see below) then sudo apt install taskTask 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 taskTask team
AlpineCloudsmith setup.alpine.sh then apk add taskTask team
macOSbrew install go-task/tap/go-task or brew install go-taskTask team / Homebrew
Windowswinget install Task.Task (also Scoop, Chocolatey: community)Task team / community
npmnpm install -g @go-task/cliTask team
Go toolchaingo install github.com/go-task/task/v3/cmd/task@latestβ€”
Go project-pinnedgo get -tool github.com/go-task/task/v3/cmd/task@latest then go tool task …—
Script / CIOfficial install.sh script (see below)Task team
GitHub Actionsuses: go-task/setup-task@v2Task team
Otherspip go-task-bin, mise, pacman go-task, Nix, MacPorts, FreeBSD pkg install taskCommunity (may lag)

The two installs that pipe a script into a shell:

sh
# 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/bin

Note: 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 tool pinning 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

  1. True incremental file builds (C/C++, LaTeX, data pipelines) that rely on pattern rules, implicit rules, automatic variables or generated dependency files (-MMD).
  2. Zero-install requirements: tarball builds (./configure && make), distro packaging, minimal containers, environments where you can't add binaries.
  3. Recursive builds that need a shared jobserver for a global -j limit across sub-makes.
  4. Toolchains that expect Make (autotools, kernel-style Kbuild, many language ecosystems' conventions).
  5. 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

PatternMakefileTaskfile.yml
Phony target.PHONY: test
test:
(tab)go test ./...
test:
cmds: [go test ./...]
VariableBIN := bin/appvars: { BIN: bin/app } β†’ {{.BIN}}
Overridable defaultGOFLAGS ?= -vDefine it in root vars:, or use the default function (see Variables above)
Shell-computedVERSION := $(shell git describe --tags)VERSION: { sh: git describe --tags }
Exported envexport CGO_ENABLED=0env: { CGO_ENABLED: '0' }
Prereqsall: lint test buildall: { deps: [lint, test, build] } (parallel!) or cmds: [{task: lint}, …]
Default goalfirst target / .DEFAULT_GOAL := helptask named default
Ignore error-rm -f x- cmd: rm x
ignore_error: true (or rm -f x)
Silence echo@echo hisilent: true (root, task, or command)
Run in subdircd web && npm ci or $(MAKE) -C webdir: web
Includeinclude common.mkincludes: { common: ./common.yml }
File targetbin/app: $(wildcard *.go)sources: ['**/*.go'], generates: [bin/app]
Pattern rule%.min.js: %.jsNo direct equivalent; for: sources loop or wildcard task
Parallel runmake -j4 a btask -p -C 4 a b
Arg passthroughmake test ARGS="-run X"task test -- -run X β†’ {{.CLI_ARGS}}
Helpgrep-based help targetdesc: + 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
yaml
# 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 bin

Docker image only when inputs change (the pattern from the official docs):

yaml
  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:

yaml
  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

yaml
  build:*:                         # task build:linux, task build:windows
    vars: { GOOS: '{{index .MATCH 0}}' }
    env:  { GOOS: '{{.GOOS}}' }
    cmds:
      - go build -o bin/app-{{.GOOS}} ./cmd/app

Parallel deps with ordering where it matters

yaml
  ci:
    desc: Lint+test in parallel, then build
    deps: [lint, test]       # concurrent
    cmds:
      - task: build          # runs after both deps succeed

How 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.

KeyWhereWhat it means
versionroot (required)Schema version: '3' (or a semver). Missing β†’ exit 107
includesrootOther Taskfiles, namespaced (ns:task)
outputrootinterleaved (default), group, prefixed
methodroot/taskchecksum (default), timestamp, none
runroot/taskalways (default), once, when_changed
silentroot/task/cmdDon't echo commands (output still shows)
varsroot/include/task/callTemplate variables: literal, sh:, ref:, map:
envroot/taskEnvironment exported to commands
dotenvroot/task.env files; first file wins; not in includes
set / shoptroot/task/cmdShell options (pipefail, globstar)
tasksrootMap of task name β†’ task definition (or shorthand string/list)
desc / summarytaskShort help for --list; long help for --summary
aliases / label / internaltaskAlternate names; log label; hide from CLI/listing
cmdstaskOrdered commands; each item may be a string, {cmd: …}, {task: …}, {defer: …}, {for: …}
depstaskTasks run concurrently before cmds
sources / generatestaskInputs/outputs for up-to-date checks and watch
statustaskShell checks; all pass β†’ skip
preconditionstaskShell checks; any fail β†’ error with msg
iftask/cmdShell/template condition; false β†’ skip silently
requirestaskRequired vars, optional enum
prompttaskConfirmation question(s)
dirtask/includeWorking directory (created if missing)
platformstask/cmdRestrict to OS/arch
ignore_error, timeout, failfast, watchtask/cmdError, 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)

  1. Install and verify: pick a method from the table above and run task --version.
  2. Scaffold: task --init in the repo root creates a starter Taskfile.yml with a default task.
  3. Add the schema comment for editor validation.
  4. Port phony targets first: one task per Make target, cmds copied over. Replace $(VAR) with {{.VAR}}, $@/$< with explicit names, and @ with silent.
  5. Move shared values into root vars/env, and secrets into .env loaded via dotenv.
  6. Decide ordering: use deps where concurrency is safe, and task: calls in cmds where order matters.
  7. Add desc to every public task. Mark helpers internal: true.
  8. Add incrementality: sources/generates for builds, status for external artifacts.
  9. Add guardrails: preconditions for required tools, requires for inputs, prompt for destructive tasks.
  10. Validate: task --list, task --dry <task>, task --summary <task>, then real runs. Use task --status build in CI to assert freshness.
  11. Commit Taskfile.yml, add .task/ and .env* (except examples) to .gitignore, and install Task in CI (go-task/setup-task@v2 or the install script with a pinned version).

Complete example: Taskfile.yml for a Go service

yaml
# 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 too

Try 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." deps are concurrent with no ordering. Use cmds: [{task: a}, {task: b}].
  • cd foo in one command, wrong directory in the next. Each cmds entry is a new shell. Use dir: or a single multiline command (|).
  • $MYVAR is empty in a command. It's in vars, not env. Use {{.MYVAR}} or add it to env:.
  • task build VERSION=2 is ignored. A constant in the task's own vars: beats the CLI. Move the default to root vars or use | default.
  • Env value from Taskfile ignored. The process environment wins by default. unset it 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 sources globs are relative to the task's dir, that exclude comes after the positive glob, and that the .task/ directory isn't shared across checkouts. Use label per variant. Run task --status to inspect.
  • Edited the binary but no rebuild. By design, checksum mode only checks that generates exist.
  • Dry run had side effects. sh: vars execute during --dry.
  • prompt fails in CI. There is no TTY. Use task --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:.
  • dotenv in 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

  1. Migrate if more than about 80% of your Makefile is .PHONY targets, your team includes Windows developers, or you want self-documenting workflows. Start with a parallel Taskfile.yml that mirrors existing targets, run both in CI for a sprint, then delete the Makefile, or leave a thin shim (%: ; @task $@) for muscle memory.
  2. Pin Task's version in CI (install script tag, setup-task version input, or go tool in go.mod). The project ships frequently, and a v4 is on the roadmap.
  3. Don't migrate compiled-artifact graphs that depend on pattern rules. Wrap them instead (task build β†’ make -j).
  4. 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-dir in v3.52.0, per-command timeout in 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.

Other information on go-task.

More places to find me
Mental Health
follow me on Mastodon