4.12.1 Dynamic Actions with ctx.actions.map_directory()
extraOrdinary 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.
input directory handle
output directory handle
top-level callback
lists files under the produced input tree
registers actions that write under outs
The callback registers actions. Bazel executes those actions and each output stays under the directory declared during analysis.
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.
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
ExpandedDirectorytemplate_ctxDirectoryExpanderctx.actions.declare_directory()Footnotes
-
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 -
actions -
declare_directory()creates tree artifacts whose contents are not directly accessible from normal Starlark analysis. ↩ -
ExpandedDirectory -
childrenanddirectoryfields passed to themap_directory()callback. ↩ -
template_ctx - callback context methods:
args(),declare_file(),declare_subdirectory(), andrun(). ↩ -
Bazel Dynamic Actions - callback constraints, listing-versus-reading distinction, motivating examples, and caution around the patched
read()demo. ↩1 ↩2 ↩3 -
Haskell Builds at Scale: Comparing Bazel and Buck2 - Andreas Herrmann, Tweag by Modus Create - static action graph pressure,
haskell_moduleplus Gazelle workaround, and Buck2 dynamic-dependency comparison. ↩ -
DirectoryExpander - expands
declare_directory()tree artifacts forArgs.add_all()map_eachcallbacks. ↩