3.4.1 Genrule (The Escape Hatch)

Bazel normally turns structured attributes into typed actions: cc_library compiles C++, java_test runs a test class. But some BUILD-file jobs are too one-off to justify a dedicated rule — rewriting a config template, transcoding a text file, stitching a small script's output into another target's inputs. genrule is Bazel's built-in escape hatch for exactly that case: a BUILD-level primitive that wraps a shell command as an action, with its inputs and outputs tracked like any other node in the graph.1

It is tempting because it asks for very little. You list inputs, outputs, and a cmd, and Bazel schedules the command with the same sandboxing, caching, and dependency tracking it applies to every other action. The trade-off is that cmd is ordinary shell, so every detail that a real rule would handle for you — declaring tools, resolving paths, picking a shell for the host platform — becomes the BUILD author's responsibility.1

Anatomy of a genrule

Four attributes do the work:

genrule(
    name = "generate_config",
    srcs = ["config.template"],
    outs = ["config.h"],
    cmd = "sed 's/VERSION/1.0/' $(location config.template) > $(RULEDIR)/config.h",
)

srcs lists the input files that the command will read. outs lists the files the command must produce. If any declared output is missing when the command finishes, the build fails. cmd is a Bash one-liner (by default) that Bazel runs inside the action sandbox. The action's mnemonic is always Genrule, which is how it shows up in build output and in routing rules like --strategy=Genrule=local from 2.5.2 Strategies & Mnemonics.2

The build-maintenance message BUILD file shows the same shape in a Level 3 project: declared srcs and outs, plus a cmd that combines $(location ...), $<, $@, and $(@D) instead of guessing paths.

Use Make variables, not guessed paths

The single most common genrule bug is treating cmd as if it ran from the source tree. It does not. Bazel runs the command inside a sandbox rooted at the workspace, with a stable layout Bazel controls and may change across flags or versions. Hand-rolled paths — ../data/input.json, bazel-bin/pkg/out.txt, anything relative to the source checkout — break as soon as the layout shifts.3

Won't build
genrule(
    name = "bad_relative_path",
    outs = ["bad_relative_path.txt"],
    cmd = "cat config/input.txt > $@",
)
ERROR: .../config/BUILD.bazel:27:8: Executing genrule //config:bad_relative_path failed
cat: config/input.txt: No such file or directory
ERROR: Build did NOT complete successfully

The key line is the shell error, not the final build summary. Bazel ran the action, but because input.txt was never declared in srcs, the sandbox did not stage it at config/input.txt.

The fix is the set of "Make variables" that Bazel expands inside cmd into the actual paths the action will see. They are the API between a genrule and the sandbox:4

  • $(location LABEL) / $(locations LABEL) — path(s) to a target's files. The label must appear in srcs, outs, or tools.
  • $@ — shorthand for the single output file, equivalent to $(locations :out) when there is exactly one output. Errors if outs has more than one entry.
  • $< — shorthand for the single input file. Errors if srcs has more than one entry.
  • $(SRCS) / $(OUTS) — space-separated lists for the multi-input or multi-output case.
  • $(RULEDIR) — the output directory for this target, always ending in the target's package path. Prefer this over the older $(@D) because it behaves the same regardless of how many files are in outs.4

For tools specifically, $(execpath LABEL) is the recommended form. It resolves to the path at which Bazel will stage the tool inside the action, and the label must be declared in tools (or srcs/outs) so Bazel knows to bring the file along.4

The genrule-sandbox-paths generated_good target combines the recommended forms in one rule: $(execpath :converter) for the tool, $(location :input.txt) for the source input, and $(RULEDIR)/generated_good.txt for the declared output. The same package keeps two deliberately broken counterparts — one hard-codes a source-tree path and one references :converter without listing it in tools — to show what each Make-variable rule is actually preventing.

Reproduce this error

Tools belong in tools, not srcs

A genrule that shells out to a checked-in script, a generated binary, or any other executable needs two things from Bazel: the tool has to show up inside the sandbox, and Bazel has to rebuild the action when the tool itself changes. Both require the tool to be declared — and the attribute for that is tools, not srcs.

sh_binary(
    name = "extract_name",
    srcs = ["extract_name.sh"],
)

genrule(
    name = "extracted",
    srcs = ["config.json"],
    outs = ["name.txt"],
    tools = [":extract_name"],
    cmd = "$(execpath :extract_name) $(location config.json) > $@",
)

The distinction matters for two reasons. tools is built in the execution configuration — the platform the build is running on, which for cross-compilation differs from the target platform — so the host-side binary is what actually lands in the sandbox.5 And Make variables only resolve LABELs that the target has actually declared, so skipping the tools entry makes $(execpath ...) a hard error at analysis time, long before any flaky behavior at runtime.4

Won't build
genrule(
    name = "missing_tool_decl",
    srcs = ["input.txt"],
    outs = ["missing_tool_decl.txt"],
    cmd = "$(execpath :converter) $(location :input.txt) > $@",
)
ERROR: .../config/BUILD.bazel:38:8: in cmd attribute of genrule rule //config:missing_tool_decl:
label '//config:converter' in $(location) expression is not a declared prerequisite of this rule
ERROR: Analysis of target '//config:missing_tool_decl' failed; build aborted

This one fails during analysis, before Bash or the sandbox runs. $(execpath :converter) is a promise that :converter is a declared input/tool of the action. Bazel rejects the target because the tools = [":converter"] edge is missing.

The anti-pattern to avoid is trusting the host PATH:

Anti-pattern
genrule(
    name = "untracked_tools",
    srcs = ["archive.tar"],
    outs = ["name.txt"],
    cmd = "tar xf $< | jq -r .name > $@",
)

That cmd relies on two undeclared dependencies that Bazel cannot see. The action may succeed on a developer's laptop, fail on CI, and cache-hit across toolchain upgrades that should have invalidated it. For larger projects, an explicit tool-tracking ruleset such as rules_sh packages several shell tools behind a single sh_binaries target that plugs into genrule's toolchains attribute and exposes each bundled tool as a Make variable inside cmd.5

Three debugging pearls

Most genrule failures collapse to one of three misconceptions:6

  • Only declared dependencies are available. If a file is not in srcs, outs, or tools, it is not in the sandbox. bazel query 'deps(//pkg:target)' is the fastest way to confirm what the action actually sees. The bad_relative_path genrule reproduces this directly: the cmd reads config/input.txt but never declares it in srcs, so the sandboxed action fails with cat: config/input.txt: No such file or directory.
  • The working directory is the workspace root, not the BUILD file's package. Patterns like ../data/config.json fail because the action is not standing where the BUILD file is. Use $(location //data:config.json) and let Bazel resolve the path.
  • Output paths change. Do not hardcode them. They shift with --compilation_mode, --platforms, and output-tree conventions. Always write to $@ or $(RULEDIR)/name. When the action's real layout is unclear, bazel aquery //pkg:target prints the expected inputs and outputs for the effective configuration — the generated_good aquery command is the runnable version, and its output lists :converter, input.txt, and bazel-out/.../config/generated_good.txt exactly the way this debugging step expects.

When a command still misbehaves, bazel build --sandbox_debug //pkg:target preserves the sandbox contents on disk so the command can be inspected and replayed exactly as Bazel invoked it — the Level 5 sandbox-debugging playbook in 5.8.2 Diagnosing Sandbox Issues extends this pattern.6

The Bash problem

genrule defaults to evaluating cmd as a Bash command. Starting with Bazel 1.0, every other built-in rule can build on Windows without a Bash install — genrule is an explicit exception, together with sh_binary, sh_test, and Starlark rules that call ctx.actions.run_shell().7 For a repository that wants to build cross-platform without requiring developers to install Bash on Windows, this is the single biggest practical reason to avoid genrule for trivial file operations.

Two workarounds are common:

  • For the "I just want to copy a file / write a small text file / run a native binary" cases, reach for purpose-made rules in bazel-skylib (copy_file, write_file, native_binary). They are implemented with Starlark actions and do not require Bash on Windows.7
  • For genuinely platform-specific commands, genrule also accepts cmd_bat and cmd_ps attributes that take precedence over cmd on Windows. This lets one target express a Bash command for Linux/macOS and a cmd.exe or PowerShell command for Windows in the same rule.

When to graduate

Wrapping a repeated genrule in a Starlark macro is a light-touch next step — a macro is just syntax sugar that expands at loading time, so bazel query still shows a plain genrule after expansion.8 The macro pattern is covered in 4.1.2 Legacy Macros and its symbolic replacement.

A custom rule (4.2 Custom Rules, Providers & Actions) is warranted when genrule actually runs out of room. The common triggers are narrow and worth naming up front so you can tell when you have crossed them:9

  • You need to pass structured information to downstream rules — providers beyond DefaultInfo and OutputGroupInfo — so consumers can read more than "the output files of this target."
  • You need toolchain resolution: the command depends on a compiler or tool that differs across target platforms, and a hand-picked tools = [...] entry stops being enough.
  • The tool is slow to start up (the JVM / Node story) and you want a persistent worker.
  • The actions you need to run depend on which outputs the user requested, not just on the inputs.

Short of those cases, reach for genrule or a genrule-like wrapper such as bazel-lib's run_binary. Most BUILD-file authors rarely need a custom rule. The "escape hatch" covers more ground than its reputation suggests.9

For a production-shaped alternative, bazel-lib's run_binary keeps the executable in an explicit tool attribute, avoids a Bash dependency, supports directory outputs and execution requirements, and exposes the same action as a reusable factory for custom rules.10 Its focused tests are the escalation path for wrapper selection, captured streams, and exit-policy behavior. Those are repository-specific capabilities, not guarantees of the built-in genrule.

key takeaway

genrule is the BUILD-level primitive that turns a shell command into a tracked action. The discipline is narrow: declare every input in srcs, every tool in tools, every output in outs. Build paths from Make variables ($(location), $(execpath), $@, $(RULEDIR)) instead of guessing, and remember the command runs as Bash from the workspace root inside a sandbox. Reach for bazel-skylib helpers for trivial file operations, and graduate to a custom rule only when you genuinely need providers, toolchain resolution, persistent workers, or output-dependent action choices.

extra

Tuning the Genrule strategy

Because every genrule action shares the Genrule mnemonic, you can route them all together through the strategy system from 2.5.2 Strategies & Mnemonics. --strategy=Genrule=local forces genrule actions to run directly on the host instead of going through sandboxing or remote execution — a useful escape when a specific genrule has hermeticity bugs the team has not yet fixed. The older --genrule_strategy=<strategy> flag is a deprecated shorthand for the same thing.2

Check your understanding · 3 questions

1.A genrule cmd contains 'cp ../data/input.json $@'. What is wrong with this, and how should it be fixed?

Select one answer

2.Match each genrule Make variable to its meaning:

Drag each answer onto the matching prompt, or click an answer and then click a prompt

Answers
$(location LABEL)
$(execpath LABEL)
$(RULEDIR)
$@

3.True or false about genrule design decisions:

Choose True or False for each sentence

Tools listed in the 'tools' attribute are built in the execution configuration, not the target configuration.
genrule works without Bash on Windows, just like cc_binary and java_binary.
Graduating a genrule to a custom rule is warranted when you need providers beyond DefaultInfo or toolchain resolution.
0 of 3 answered

Footnotes

  1. Rulesgenrule listed among the built-in general rules as the supported mechanism for running a shell command as a build action. 1 2

  2. Commands and Options--strategy routing by mnemonic, the Genrule mnemonic, and the deprecation of --genrule_strategy as a short-hand for --strategy=Genrule=. 1 2

  3. Extension Overview — genrules declared by macros behave almost exactly as if declared directly in the BUILD file. Confirms that genrule is the canonical BUILD-level action primitive.

  4. Legacy Macros$@ as a Make variable equivalent to $(locations :out). $< as the srcs shorthand. Idiomatic use of $(location LABEL) inside cmd, which implies the label must be declared on the target. 1 2 3 4

  5. Hermetic shell scripts in Bazel — the canonical "undeclared tar / jq" bug and the sh_binaries / rules_sh pattern for tracking shell tools via the toolchains attribute and exposing them through Make variables. 1 2

  6. Enough Bazel to Be Dangerous: A Debugging Cookbook — Part 1 "Debugging GenRules": only declared deps are visible. The workspace-root working directory. Use $(location) / $@ instead of hardcoded output paths. bazel aquery for expected output paths and --sandbox_debug for sandbox inspection. 1 2

  7. Using Bazel on Windows — genrule is explicitly listed as one of the rules that still requires Bash on Windows, together with sh_binary, sh_test, and Starlark rules using ctx.actions.run_shell(). Recommends bazel-skylib rules like copy_file / write_file for the common simple cases. 1 2

  8. Legacy Macros — wrapping native.genrule inside a Starlark macro, chaining genrules, and using bazel query --output=build to see the expanded rule.

  9. What's better than a genrule? — the narrow set of reasons to write a custom rule (custom providers, persistent workers, platform-aware toolchains, output-dependent actions) and the argument that most BUILD-file authors are better served by genrule-shaped APIs such as run_binary. 1 2

  10. bazel-lib repository maplib/run_binary.bzl is the public rule/action-factory surface, with focused executable cases under lib/tests/run_binary/.