Skip to content

Starlark in Bazel

Bazel is Google’s open-source build system, designed for fast, reproducible builds at scale. Starlark is the extension language that makes Bazel customizable.

FilePurposeExample
BUILD / BUILD.bazelDefines build targets in a packagecc_binary(name = "app", srcs = ["main.cc"])
*.bzlStarlark extension files (macros, rules)def my_macro(name): ...
WORKSPACE / WORKSPACE.bazelDeclares external dependencieshttp_archive(name = "rules_go", ...)
MODULE.bazelBzlmod module definition (Bazel 6+)bazel_dep(name = "rules_go", version = "0.41.0")
.bazelrcBuild configuration flagsbuild --compilation_mode=opt

A label uniquely identifies a target:

@repo_name//package/path:target_name
│ │ │
│ │ └── Target name
│ └── Package path (directory with BUILD file)
└── Repository name (@ for external, omit for current)

Examples:

# Same package
":my_lib"
# Different package, same repo
"//src/lib:utils"
# External repository
"@rules_go//go:def.bzl"

Bazel builds happen in three distinct phases:

┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Loading Phase │────▶│ Analysis Phase │────▶│ Execution Phase │
│ │ │ │ │ │
│ • Parse BUILD │ │ • Run rule impl │ │ • Run actions │
│ • Evaluate .bzl │ │ • Create actions│ │ • Produce files │
│ • Expand macros │ │ • Return provs │ │ • Cache results │
└─────────────────┘ └─────────────────┘ └─────────────────┘
Starlark Starlark Sandboxed

During loading, Bazel:

  1. Parses BUILD and .bzl files
  2. Evaluates Starlark code
  3. Expands macros into rule instantiations
  4. Builds the target graph
# BUILD file - evaluated during loading
load("//build_defs:macros.bzl", "my_macro")
my_macro(name = "foo") # Macro expands here
cc_library(
name = "bar",
srcs = ["bar.cc"],
deps = [":foo"],
)

During analysis, Bazel:

  1. Runs each rule’s implementation function
  2. Creates actions (commands to run later)
  3. Returns providers (information for dependents)
def _my_rule_impl(ctx):
# Create an action
output = ctx.actions.declare_file(ctx.label.name + ".out")
ctx.actions.run(
outputs = [output],
inputs = ctx.files.srcs,
executable = ctx.executable._compiler,
arguments = [f.path for f in ctx.files.srcs] + [output.path],
)
# Return providers
return [
DefaultInfo(files = depset([output])),
MyInfo(data = output),
]

During execution, Bazel:

  1. Determines which actions need to run
  2. Executes actions in parallel (respecting dependencies)
  3. Caches action results for incremental builds

A macro is a Starlark function that instantiates rules. Macros are expanded during the loading phase.

macros.bzl
def cc_test_suite(name, srcs, deps = []):
"""Creates a test target for each source file."""
for src in srcs:
test_name = src.replace(".cc", "_test")
native.cc_test(
name = test_name,
srcs = [src],
deps = deps,
)
# Recommended for Bazel 8+
my_macro = macro(
implementation = _my_macro_impl,
attrs = {
"srcs": attr.label_list(allow_files = True),
},
)
def _my_macro_impl(name, srcs):
native.genrule(
name = name,
srcs = srcs,
outs = [name + ".out"],
cmd = "cat $(SRCS) > $@",
)

A rule has full control over the analysis phase. Rules can create actions, define providers, and access Bazel internals.

rules.bzl
def _example_rule_impl(ctx):
output = ctx.actions.declare_file(ctx.label.name + ".txt")
ctx.actions.write(output, "Hello from " + ctx.label.name)
return [DefaultInfo(files = depset([output]))]
example_rule = rule(
implementation = _example_rule_impl,
attrs = {
"message": attr.string(default = "Hello"),
},
)

Providers pass information between rules. They’re the primary way rules communicate.

# Define a provider
MyInfo = provider(
doc = "Information from my rule",
fields = {
"output": "The main output file",
"data": "Additional data files",
},
)
def _my_rule_impl(ctx):
# Collect from dependencies
all_data = []
for dep in ctx.attr.deps:
if MyInfo in dep:
all_data.extend(dep[MyInfo].data)
# Return for dependents
return [
DefaultInfo(files = depset([output])),
MyInfo(output = output, data = all_data),
]

A depset is an efficient collection for accumulating transitive dependencies:

def _my_rule_impl(ctx):
# Collect transitive files efficiently
transitive_srcs = depset(
direct = ctx.files.srcs,
transitive = [dep[MyInfo].srcs for dep in ctx.attr.deps],
)
return [MyInfo(srcs = transitive_srcs)]

Actions are the commands Bazel executes:

# Run a command
ctx.actions.run(
outputs = [output],
inputs = depset([input]),
executable = ctx.executable._tool,
arguments = ["--input", input.path, "--output", output.path],
mnemonic = "MyAction",
progress_message = "Processing %s" % input.short_path,
)
# Run a shell command
ctx.actions.run_shell(
outputs = [output],
inputs = [input],
command = "cat $1 | tr a-z A-Z > $2",
arguments = [input.path, output.path],
)
# Write a file
ctx.actions.write(
output = output,
content = "Generated content",
)

Aspects traverse the dependency graph, augmenting targets with additional actions:

def _my_aspect_impl(target, ctx):
# Run on every target in the graph
if hasattr(ctx.rule.attr, "srcs"):
# Do something with srcs
pass
return []
my_aspect = aspect(
implementation = _my_aspect_impl,
attr_aspects = ["deps"], # Propagate along deps
)

Bazel’s Starlark has some restrictions compared to standard Starlark:

FeatureBazel Starlark
RecursionDisabled by default
Top-level forDisabled by default
print()Available (outputs to console)
load()Only at top level
Mutable globalsFrozen after load

This documentation is based on Bazel source code at commit a147aac: