4.2.3 Args & Command Lines

ctx.actions.args() is the boundary between "I know what this action needs" and "I can safely spell that need as a command line." It lets a rule author pass files, strings, lists, and depsets to an action without flattening every transitive input during analysis. When the rule calls use_param_file(), it also gives Bazel enough structure to choose inline arguments or a param file at execution time.1 If 4.2.2 Actions taught you that actions are (inputs, command) -> outputs, this item is about making the command part scale.

Args keeps command lines structured until execution
Rule data enters one command-line object. Bazel expands that object when the action actually runs.
Rule implementation
Collect values
analysis phase
Files ctx.files.srcs
Depsets transitive_headers
Flags "--out", out
ctx.actions.args()
Store command-line shape
no eager flattening
add

Adds one value, for example --out then out.

add_all

Expands a list or depset to repeated argv items.

format_each

Maps each include to argv such as -Ia, -Ib.

The action receives the object as arguments = [args].

Action execution
Spell argv only for a running action
final command line
Inline argv
tool --out out -Ia -Ib
Optional: use_param_file()
tool @bazel-out/.../params
Args is the analysis-to-execution boundary for command lines: keep structured values through analysis, then use param files only when use_param_file() opts in and the tool supports the format.

Why Lists Stop Scaling

The tempting version of a rule implementation builds a Python-style list:

arguments = ["--out", out.path]
arguments += ["-I%s" % f.short_path for f in headers.to_list()]

That works for a tiny rule. It is the wrong shape for a production ruleset. As covered in 4.1.5 depset vs list, transitive information should travel through providers as depsets, because a depset can share the same dependency subgraph across many targets instead of copying it into a fresh list at every node.2 Flattening that depset with to_list() while building command lines throws away the sharing benefit and can create O(N^2) time or memory cost across a large target graph.3

Args keeps the command line as a structured object. It can hold a depset directly and delay expansion until Bazel is preparing the action for execution.1 If the action is already cached, the expensive "turn this whole transitive closure into strings" step may never be needed.4

Build the Command Line as Data

Create one with ctx.actions.args(), append to it, and pass it as an element of the action's arguments list.5

def _include_arg(file):
    return file.short_path

def _impl(ctx):
    out = ctx.actions.declare_file(ctx.label.name + ".out")
    transitive_headers = depset(
        transitive = [dep[HeaderInfo].headers for dep in ctx.attr.deps],
    )
    inputs = depset(
        direct = ctx.files.srcs,
        transitive = [transitive_headers],
    )

    args = ctx.actions.args()
    args.add("--out", out)
    args.add_all(ctx.files.srcs, before_each = "--src")
    args.add_all(
        transitive_headers,
        format_each = "-I%s",
        map_each = _include_arg,
    )

    ctx.actions.run(
        executable = ctx.executable._tool,
        arguments = [args],
        inputs = inputs,
        outputs = [out],
        mnemonic = "CompileThing",
    )

The example uses add() for one value, add_all() for repeated values, and format_each plus map_each when the tool expects a transformed spelling. Args automatically converts File values to paths, so the rule does not need to write out.path or [src.path for src in ctx.files.srcs] just to make the command line valid.1 Keep the map_each callback as a top-level def: Bazel documents nested closures as disabled by default because they can accidentally retain large analysis-phase objects into the execution phase.6

Use the extra "arg name" form only when you want one flag before a non-empty sequence:

args.add_all("--sources", ctx.files.srcs)

That produces --sources a.cc b.cc when srcs is non-empty, and omits --sources when it is empty by default.7 For repeated flag-value pairs, use before_each or format_each, as in --src a.cc --src b.cc or -Ia -Ib.

add_joined() is for tools that want many values collapsed into one argument, such as a comma-separated list. It follows the same lazy processing model as add_all(), but joins the expanded values with join_with and optionally applies format_joined.8

Param Files Are Part of the Rule Contract

Operating systems limit command-line length, and Windows tends to make long argument vectors fail earlier than Unix-like systems. Args.use_param_file() lets Bazel spill the command line into a param file when needed, replacing the spilled arguments with a pointer such as @path/to/params.9

args = ctx.actions.args()
args.add("--out", out)
args.add_all(transitive_inputs)
args.use_param_file("@%s")
args.set_param_file_format("multiline")
args.set_param_file_path(ctx.label.name + ".params")

The rule author still chooses the format and, when necessary, the filename. Some tools expect @file, some expect --flagfile=file, and some do not support param files at all. Bazel's default param-file format is shell. Available formats also include multiline and flag_per_line, where flag_per_line is designed for flag libraries that expect one --flag=value style entry per line.10 Use set_param_file_path() when the tool expects a fixed filename or when multiple Args objects need coordinated spill files instead of Bazel's default name derived from the action's primary output.10 When debugging, remember that Bazel may avoid materializing the param file in the output tree for efficiency. --materialize_param_files asks Bazel to write it so you can inspect it.9

Prefer run() Over Shell Strings

ctx.actions.run() invokes an executable with a structured argument list. ctx.actions.run_shell() invokes a shell command and then exposes arguments through shell variables such as $1, $2, and $@.11 If an Args object has unknown size, later positional indexes in run_shell() can become unpredictable because the object is flattened into individual arguments before the shell sees them.11

That is one reason to keep ordinary tool execution in run(): less quoting logic, fewer OS-specific shell assumptions, and a clearer action contract.4 Use run_shell() when the shell is genuinely part of what the rule promises, not as a convenience for string concatenation. The broader production version of this decision includes inputs, tools, environment, mnemonics, and scheduling tags in 4.4.3 Action Execution Contract.

For a complete command line, see the mini-ruleset's GlyphCompile Args construction. It keeps sources and provider-derived inputs lazy, uses repeated flags, selects a param-file format, and passes the resulting Args object to run().

key takeaway

Use Args when command-line data can grow with dependencies. Lists are fine for small rule-local constants. Depsets and ctx.actions.args() are the scalable path for transitive files, repeated flags, and param-file fallback.

The command line is not just a string. It is part of the action's public behavior, so choose the spelling your tool accepts, keep transformations lazy, and make param-file support explicit.

Check your understanding · 4 questions

1.Why should a rule use ctx.actions.args() for command lines that include transitive files?

Select one answer

2.Which statements describe correct Args usage?

Select all that apply

3.True or false: command-line construction boundaries for custom rules.

Choose True or False for each sentence

Passing a File to Args lets Bazel convert it to a path lazily.
Calling depset.to_list() before building every command line preserves depset sharing.
--materialize_param_files can help debug a param file that Bazel would otherwise avoid writing to the output tree.
The rule author must choose a param-file spelling that the tool actually supports.

4.Match each Args spelling to the command-line shape it is meant to model.

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

Answers
args.add_all("--sources", ctx.files.srcs)
args.add_all(ctx.files.srcs, before_each = "--src")
args.add_joined(values, join_with = ",")
args.use_param_file("@%s"); args.set_param_file_format("flag_per_line")
0 of 4 answered

Footnotes

  1. Args — overview of memory-efficient command-line construction and deferred depset expansion. 1 2 3

  2. Optimizing Performance — depsets share transitive dependency data instead of repeatedly copying lists.

  3. Optimizing Performance — warning that flattening depsets with to_list() can create O(N^2) costs.

  4. Writing Bazel rules: moving logic to execution — practical ctx.actions.run() and Args use in rule actions, including lazy depset iteration. 1 2

  5. actionsctx.actions.args() and the arguments parameter for run() / run_shell().

  6. Argsmap_each top-level function requirement and allow_closure caveat.

  7. Args — alternate arg-name form and omit_if_empty behavior for add_all().

  8. Argsadd_joined() lazy processing and formatting parameters.

  9. Argsuse_param_file() behavior, command-line limits, and --materialize_param_files. 1 2

  10. Argsset_param_file_format() formats and default. set_param_file_path() overrides Bazel's default param-file name. 1 2

  11. actionsrun_shell() argument indexing and Args flattening behavior. 1 2