4.2.2 Actions

An action is the part of a custom rule that says, "when this output is needed, run this executable with these inputs and expect these outputs." The rule implementation still runs during analysis, so it does not compile, copy, read, or write files directly. It declares File objects and registers actions in Bazel's action graph. Bazel executes only the necessary actions later, during execution.1,2

Actions are contracts recorded during analysis
Rule code records inputs, including the executable, plus outputs, arguments, and environment. No process runs yet.
copy_rule.bzl analysis
File handle
out = ctx.actions.declare_file(ctx.label.name + ".txt")
Action contract
ctx.actions.run(
outputs = [out],
inputs = [src],
executable = ctx.executable._copier,
arguments = [src.path, out.path],
)

Starlark describes the output. It does not open or write the file.

Action graph
Contract recorded
no process yet
inputs src, tool
argv src -> out
output out
id CopyText
Execution
Output requested
run only then
needed run / cache / remote
not needed contract stays idle
Analysis creates the action contract. Execution uses it only for requested outputs.

The Rule Author's Boundary

The implementation function receives ctx, reads attributes and dependency providers, declares outputs, and calls ctx.actions methods.3 That is planning work. The external tool, shell command, file write, or template expansion happens later if some requested output depends on it.4

This is the bridge from 2.2 Three Phases of a Build into rule authoring. Level 2 introduced the loading, analysis, and execution split. Here you are using that split deliberately. The rule author defines the action graph during analysis, while Bazel decides at execution time which actions are already cached, which can run in parallel, and which need to run at all.5

The smallest useful mental model is:

declared inputs + command/tool + declared outputs = action

Bazel can schedule, cache, sandbox, and inspect an action because that contract is explicit. If the tool reads a file that was not declared as an input, the rule has hidden information from Bazel. If the tool writes an output that was not declared, downstream targets cannot depend on it through the normal graph.

Declare Outputs First

Generated files are represented by File objects during analysis. A File is not an open file handle and cannot be read or written by Starlark rule code. It is a handle passed to action-creating APIs.6

For derived outputs, use ctx.actions.declare_file() or ctx.actions.declare_directory():

def _copy_impl(ctx):
    out = ctx.actions.declare_file(ctx.label.name + ".txt")
    # Next: register exactly one action that produces out.

declare_file() creates a generated file under the current package's output tree. Declaring a file is separate from creating the action that emits it: you pass the returned File object to an action construction function afterward.7

Tree artifacts use ctx.actions.declare_directory(). They are still declared outputs, but their contents are not directly visible to ordinary Starlark during analysis. Action command lines can expand their contents with Args.add_all() when that is the right shape.8 Dynamic action generation from tree contents is a later, experimental topic in 4.12 Experimental Rule Patterns.

Predeclared outputs are different. If the rule has output attributes such as attr.output, Bazel creates output labels during loading, and the implementation reads their File objects from ctx.outputs instead of calling declare_file().9

Register An Action

The most common action API is ctx.actions.run(): invoke a tool directly with declared inputs, declared outputs, arguments, and usually a stable mnemonic.10

def _copy_impl(ctx):
    src = ctx.file.src
    out = ctx.actions.declare_file(ctx.label.name + ".txt")

    ctx.actions.run(
        outputs = [out],
        inputs = [src],
        executable = ctx.executable._copier,
        arguments = [src.path, out.path],
        mnemonic = "CopyText",
    )

    return [DefaultInfo(files = depset([out]))]

copy_file = rule(
    implementation = _copy_impl,
    attrs = {
        "src": attr.label(allow_single_file = True),
        "_copier": attr.label(
            default = "//tools:copier",
            executable = True,
            cfg = "exec",
        ),
    },
)

The private _copier attribute models a build-time tool. Marking it executable = True makes it available through ctx.executable._copier, and cfg = "exec" builds it for the execution platform rather than the target platform.11 Full toolchain-based selection comes later in 4.6.2 Defining, Registering & Accessing Toolchains. For a first custom rule, a private executable attr is often enough.

Return the output through DefaultInfo if it is part of the target's normal build result. Registering an action teaches Bazel how the file can be produced, but DefaultInfo(files = depset([out])) teaches Bazel that this file is the target's default output when someone builds or depends on the target.12

Pick The Narrowest Action API

ctx.actions.run() is the default for real tools. It avoids shell quoting, keeps the executable explicit, and makes the rule easier to move across platforms. A common progression makes this concrete: early rule prototypes use run_shell() for brevity, then move more logic into a builder binary invoked with ctx.actions.run() because direct tool execution is less error-prone and less OS-specific.13

Use ctx.actions.run_shell() when the shell is part of the rule's intended contract, not just because it is convenient. The API runs a shell command and receives outputs, inputs, tools, arguments, and environment parameters much like run().14 Shell actions are useful for small glue, but they make quoting, host tools, platform differences, and environment inheritance part of the design problem.15 The next item, 4.2.3 Args & Command Lines, goes deeper on command-line construction and why Args is usually better than hand-built strings.

Some action APIs do not run an external executable:

APIUse when
ctx.actions.write()The file content is known from analysis-time data, such as a small launcher or metadata file.16
ctx.actions.expand_template()You have a UTF-8 template file and a substitution dictionary. The stable surrounding text belongs in the template.17
ctx.actions.symlink()The rule's public output should be a symlink to another file, directory, or explicit target path.18
ctx.actions.do_nothing()You need a graph node with inputs but no command or outputs. This is an escape hatch, not normal generation.19

The official Rules Tutorial demonstrates the difference between declaring a file and writing it: after declare_file(), a rule must register an action such as ctx.actions.write() or Bazel has no generating action for the output.20

Inputs Are Part Of Correctness

Action inputs include source files, generated files from dependencies, tools, and sometimes transitive depsets of files.21 From Bazel's perspective, compilers, standard libraries, code generators, and helper scripts are inputs too, not background facts about the machine.22

That is why rules usually separate user inputs from tools:

attrs = {
    "srcs": attr.label_list(allow_files = [".proto"]),
    "_compiler": attr.label(
        default = "//tools:proto_compiler",
        executable = True,
        cfg = "exec",
    ),
}

The user's srcs describe what the target processes. The private _compiler describes how the rule implementation performs that processing. Both affect the action. If either the source file or compiler changes, Bazel has enough information to invalidate the old result.

For large transitive inputs, keep the shape graph-friendly. ctx.actions.run(inputs = ...) accepts a list or depset, and ctx.actions.args() can defer expansion of depsets until the action command line is actually needed.23 That connects back to 4.1.5 depset vs list: do not flatten transitive data just to feed an action API that already understands depsets.

Make Actions Inspectable

Choose mnemonic values as if someone will debug them in logs, profiles, and aquery. A mnemonic is the one-word action type name, such as CppCompile, GoLink, or CopyText.24 A stable progress_message can make long builds readable, and the official API recommends pattern substitutions such as %{label}, %{input}, and %{output} instead of static strings where possible.25

This is the authoring side of 5.2.3 bazel aquery — Action Graph. Later, bazel aquery lets you inspect the post-analysis action graph: command lines, inputs, outputs, mnemonics, and related action details. The quality of that debugging experience depends partly on how clearly your rule declared its actions.

When Starlark analysis or action construction becomes memory-heavy, 5.6.6 Starlark Memory Profiling shows how to profile that cost.

Rule tests can also inspect created actions when the rule opts into Starlark testability. The Action API reference documents fields such as inputs, outputs, argv, args, env, mnemonic, content, and substitutions.26 Use argv for analysis-time assertions over ordinary command lines. args preserves more accurate Args data, including expanded output directories, but the frozen Args objects are not themselves readable during analysis.26 That testing surface belongs in 4.5 Rule Testing & Documentation, but the rule authoring habit starts here: make each action's contract crisp enough to verify.

The mini-ruleset's complete GlyphCompile and GlyphValidate actions show the same contract with a toolchain executable, structured args, declared inputs and outputs, stable mnemonics, and separate compile and validation work.

For a production code-generation case, rules_proto documents how protoc inputs, plugins, declared outputs, and output mappings become one action contract in docs/CORE_RULES.md. Use it to inspect a concrete protoc action shape. Its output-mapping API is a ruleset contract, not a universal Bazel action feature.27

key takeaway

An action is not "some code the rule runs." It is a declared execution contract: inputs, tools, arguments, environment, outputs, and identity.

Write Starlark rule code to describe that contract during analysis. Put file-reading, parsing, compiling, linking, formatting, and expensive logic in execution-phase tools registered by actions.

Check your understanding · 4 questions

1.What does a rule implementation do when it creates an action?

Select one answer

2.When should a rule author prefer ctx.actions.run() over ctx.actions.run_shell()?

Select one answer

3.Match each action API to the situation where it is the narrowest fit.

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

Answers
ctx.actions.write()
ctx.actions.expand_template()
ctx.actions.symlink()
ctx.actions.do_nothing()

4.True or false: output declaration and output exposure in custom rules.

Choose True or False for each sentence

ctx.actions.declare_file() creates a File handle, not the file's contents.
A generated output should be produced by exactly one action.
Registering an action automatically makes its output the target's default output.
Returning DefaultInfo(files = depset([out])) exposes an output as part of the target's normal build result.
0 of 4 answered

Footnotes

  1. Rules — rule implementations register actions during analysis instead of running commands directly.

  2. actionsctx.actions module provides functions that create actions.

  3. ctxctx exposes attributes, dependencies, outputs, and action declaration APIs to the implementation function.

  4. Rules Tutorialctx.actions.write() registers an action. The output is only produced when requested.

  5. Writing Bazel rules: simple binary rule — loading, analysis, and execution phases from a rule author's perspective.

  6. FileFile is an analysis-phase representation, not an open file handle.

  7. actionsdeclare_file() declares an output, and a separate action must emit it.

  8. actionsdeclare_directory() creates a tree artifact whose contents can be expanded in action commands with Args.add_all().

  9. Rules — predeclared outputs from output attributes are available through ctx.outputs.

  10. actionsrun() parameters for executable actions.

  11. Rules — private executable attributes and cfg = "exec" for implicit tool dependencies.

  12. Rules Tutorial — returning DefaultInfo(files = depset([out])) makes a generated file the target's output.

  13. Writing Bazel rules: moving logic to execution — preference for ctx.actions.run() over shell actions and moving complexity into builder tools.

  14. actionsrun_shell() executes a shell command with declared inputs, outputs, tools, arguments, and environment.

  15. Writing Bazel rules: simple binary rule — portability, quoting, host tools, and environment drawbacks of shell-based actions.

  16. actionswrite() creates a file write action from analysis-time content.

  17. actionsexpand_template() creates a template expansion action with substitutions.

  18. actionssymlink() creates a symlink output to a file, directory, or target path.

  19. actionsdo_nothing() creates an empty action for extra-action graph uses.

  20. Rules Tutorial — declared files without generating actions produce an error.

  21. RulesFile objects are passed to action-creating functions, and generated files must be outputs of exactly one action.

  22. Rules — rule writers must treat tools and libraries needed by actions as inputs.

  23. actions — action inputs can be lists or depsets, and arguments can include actions.args() objects.

  24. actionsmnemonic is a one-word action description.

  25. actionsprogress_message supports efficient %{label}, %{input}, and %{output} substitutions.

  26. Action — action introspection fields for testing rule implementations. 1 2

  27. rules_proto repository mapdocs/CORE_RULES.md is the supported orientation route, with implementation details under pkg/protoc/ reserved for escalation.