4.12.1 Dynamic Actions with ctx.actions.map_directory()

extra
Bazel 9+, experimental API

Ordinary rule actions are planned during analysis: the rule implementation declares outputs, registers actions, and Bazel executes only the needed actions later. ctx.actions.map_directory() is an experimental Bazel 9+ exception at the edge of that model, so treat it as version-sensitive design vocabulary rather than a stable default. It lets a rule declare input and output tree artifacts up front, then ask Bazel to call a top-level callback after the input directories exist so the callback can register finer-grained actions from their directory listings.1 That makes it a specialized follow-up to 4.2.2 Actions, not a replacement for the static action graph model from 2.2.3 Static Action Graph.

How can generated filenames create actions without opening the whole graph?
map_directory() expands one declared tree boundary: Bazel lists its children, calls a template, then runs scoped actions that fill a declared output tree.
Experimental · Bazel 9+ Version-sensitive API. Prefer ordinary analysis-time actions unless a produced tree directory must determine the follow-up action set.
Analysis
Declare the boundary
tree handles first
generated_srcs/

input directory handle

generated_outs/

output directory handle

implementation = _map_each

top-level callback

Execution
Build input tree
ordinary action output
generated_srcs/
alpha.pb
beta.pb
gamma.pb
Callback
Map listing to actions
execution-time template
Only this step is dynamic
ExpandedDirectory.children

lists files under the produced input tree

for child in srcs.children:
template_ctx.run()

registers actions that write under outs

template_ctx.declare_file(...)
Scoped actions run
Populate the declared output tree
ordinary execution after registration
generated_outs/
alpha.pb.out
beta.pb.out
gamma.pb.out

The callback registers actions. Bazel executes those actions and each output stays under the directory declared during analysis.

Boundary stays constrained
directory listing to actions
not arbitrary target graph
not arbitrary output creation
Declared tree artifacts frame the input, the callback reacts to children, and predeclared output directories keep the generated actions scoped.

What Becomes Dynamic

map_directory() creates multiple actions based on the files inside one or more input directories, and those actions write into one or more output directories.1 The dynamic part is the per-file action set: the callback can look at the expanded tree artifact and decide which child files get corresponding actions.

The boundary is still tight. The rule must pass dictionaries of input_directories, output_directories, tools, optional additional_inputs, optional additional_params, and execution settings when it calls ctx.actions.map_directory().1 The callback can receive the expanded directory listing, but the output directories themselves are already declared File handles. It is not allowed to mint arbitrary outputs somewhere else in the package.

That distinction matters because a tree artifact is normally opaque to Starlark. ctx.actions.declare_directory() gives the rule a generated directory handle, but the contents are not directly accessible from normal Starlark analysis. They can only be expanded in execution-oriented APIs such as Args.add_all() or, for this article, through map_directory() after the directory has been produced.2

The Smallest Rule-Side Path

The call has two pieces: the analysis-time declaration and the execution-time template callback. The example below shows the complete Starlark shape inside the rule: a generator tool first fills the input tree artifact, then a worker processes each file in that directory. The two tools and the config target still need ordinary executable/file targets in the surrounding package.

def _rule_impl(ctx):
    generated_srcs = ctx.actions.declare_directory(ctx.label.name + "_srcs")
    generated_outs = ctx.actions.declare_directory(ctx.label.name + "_outs")

    ctx.actions.run(
        executable = ctx.executable._generator,
        outputs = [generated_srcs],
        arguments = [generated_srcs.path],
        mnemonic = "GenerateSources",
    )

    ctx.actions.map_directory(
        input_directories = {"srcs": generated_srcs},
        output_directories = {"outs": generated_outs},
        tools = {"worker": ctx.attr._worker[DefaultInfo].files_to_run},
        additional_inputs = {"config": ctx.file._config},
        additional_params = {"mode": "compile"},
        implementation = _map_each_generated_file,
        mnemonic = "MapGeneratedSources",
    )

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

mapped_sources = rule(
    implementation = _rule_impl,
    attrs = {
        "_config": attr.label(
            default = "//tools:map_config",
            allow_single_file = True,
        ),
        "_generator": attr.label(
            default = "//tools:source_generator",
            executable = True,
            cfg = "exec",
        ),
        "_worker": attr.label(
            default = "//tools:source_worker",
            executable = True,
            cfg = "exec",
        ),
    },
)

The callback is a top-level function. The official API says nested functions and lambdas are not allowed for implementation.1 Its first argument is template_ctx. The rest are keyword-only dictionaries that mirror the maps passed to map_directory().

def _map_each_generated_file(
        template_ctx,
        *,
        input_directories,
        output_directories,
        tools,
        additional_inputs,
        additional_params):
    srcs = input_directories["srcs"]
    outs = output_directories["outs"]

    for child in srcs.children:
        # This compact example assumes the generator emits a flat directory.
        out = template_ctx.declare_file(
            child.basename + ".out",
            directory = outs,
        )
        args = template_ctx.args()
        args.add(child.path)
        args.add(out.path)
        args.add(additional_params["mode"])
        template_ctx.run(
            executable = tools["worker"],
            inputs = [child, additional_inputs["config"]],
            outputs = [out],
            arguments = [args],
        )

The flat-directory assumption keeps the first implementation small and makes output naming unambiguous. A production callback that preserves subdirectories must derive a relative path under srcs.directory and declare matching output subdirectories. It must not collapse two equal basenames onto one output.

Once //tools:source_generator, //tools:source_worker, and //tools:map_config exist, the BUILD-facing invocation stays small:

load("//rules:mapped_sources.bzl", "mapped_sources")

mapped_sources(name = "generated")

With matching tool targets on Bazel 9 or newer, bazel build //demo:generated first produces the input tree artifact and then expands the scoped per-file actions. The listing is still schematic about its surrounding generator, worker, config target, and production naming policy. The core tree-to-tree callback is also runnable on Bazel 9.1.0: bazel build //:mapped_files creates a generated input tree and one copied child in the declared output tree. Its map_directory.bzl is deliberately smaller than this listing, so it proves the callback boundary without pretending to exercise every optional map or execution setting.

input_directories["srcs"] is an ExpandedDirectory: it has a directory field for the original input directory and a children field for the files inside it.3 template_ctx is intentionally much smaller than ctx. It can build command lines with args(), declare files or subdirectories inside a declared directory, and register actions with run().4

The execution settings still live on the original map_directory() call. Its parameters include exec_group, toolchain, use_default_shell_env, env, execution_requirements, and mnemonic, so the generated actions should be routed the same way you would route ordinary ctx.actions.run() actions.1 If the callback invokes a tool from a non-default execution group, pair the tool attribute's cfg = config.exec("group") with the matching exec_group on map_directory(). 4.6.5 Execution Groups & Auto Exec Groups covers why those names should line up.

What The Callback Cannot Do

The callback sees the directory listing, not an open-ended analysis context. The API enforces three constraints: the callback is scoped to output directories handed to map_directory(), data must be passed through the map_directory() arguments, and the current API does not read generated file contents just because it can list generated files.5

That is why map_directory() is useful but not a general dynamic build graph escape hatch. If a generated file contains a JSON build graph, the current documented API does not let the callback read that JSON and synthesize a whole arbitrary graph from its contents. An experimental Bazel patch added a read() helper to explore that idea, but the reliable teaching point is the built-in boundary: directory listing in, scoped actions out.5

Where It Fits

The best fit is a generator or compiler pipeline where an earlier action produces a tree artifact and the next step should operate on each produced file separately. A typical example is one transpilation action per file in an opaque generated directory. Native C++ rules have historically had internal abilities around tree artifacts that ordinary Starlark rules could not express.5

This is also why dynamic-action APIs show up in language-rule conversations. The pain is visible from the other side: Bazel's ordinary static action graph means a rule cannot inspect generated module-dependency information during analysis, so rules_haskell exposes module graphs through explicit haskell_module targets and a Gazelle extension instead.6 Buck2's broader dynamic-dependency model can read a generated dependency graph and wire module actions from it, but map_directory() is narrower: it helps when filenames in a declared tree artifact determine scoped follow-up actions, not when arbitrary file contents must reshape target dependencies.

Related Directory Expansion

DirectoryExpander is easy to confuse with map_directory(). It expands a declare_directory() tree artifact inside an Args.add_all() map_each callback, returning the files recursively under the directory.7 That helps construct a command line for an action that is already being registered.

map_directory() goes one step later in the pipeline: after the input directory exists, its callback can register new template_ctx.run() actions that write into declared output directories. Use DirectoryExpander when one action needs a command line over tree contents. Reach for map_directory() only when the directory listing should create several scoped actions.

key takeaway

ctx.actions.map_directory() bends Bazel's static action graph only at a very specific point: declared tree artifacts are still the boundary, declared output directories are still the destination, and the callback gets a small template context rather than the rule's full ctx.

For production rule design, start with ordinary analysis-time actions. Use map_directory() only when a generated directory's filenames genuinely decide the follow-up action set.

Check your understanding · 3 questions

1.What is the dynamic part of ctx.actions.map_directory()?

Select one answer

2.Which statements describe the map_directory() callback boundary?

Select all that apply

3.Match each API object to its role in this pattern.

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

Answers
ExpandedDirectory
template_ctx
DirectoryExpander
ctx.actions.declare_directory()
0 of 3 answered

Footnotes

  1. actions - map_directory() creates actions from declared input directories to declared output directories. Parameter maps, callback signature, and top-level implementation requirement. 1 2 3 4 5

  2. actions - declare_directory() creates tree artifacts whose contents are not directly accessible from normal Starlark analysis.

  3. ExpandedDirectory - children and directory fields passed to the map_directory() callback.

  4. template_ctx - callback context methods: args(), declare_file(), declare_subdirectory(), and run().

  5. Bazel Dynamic Actions - callback constraints, listing-versus-reading distinction, motivating examples, and caution around the patched read() demo. 1 2 3

  6. Haskell Builds at Scale: Comparing Bazel and Buck2 - Andreas Herrmann, Tweag by Modus Create - static action graph pressure, haskell_module plus Gazelle workaround, and Buck2 dynamic-dependency comparison.

  7. DirectoryExpander - expands declare_directory() tree artifacts for Args.add_all() map_each callbacks.