Bazel subrules are a lesser-known mechanism for splitting rule functionality into smaller, reusable building blocks. They are designed to improve rule architecture by encapsulating implicit dependencies, toolchains, and action logic — without passing the entire ctx (“god object”) around.

In the past, we achieved similar reuse through plain Starlark helper functions. While that works, it often requires threading ctx through multiple layers, which reduces encapsulation and makes refactoring harder.

Subrules

A subrule is similar to a rule in that it can create actions during analysis. However, it operates under strict encapsulation constraints and exposes only a reduced context API.

Let’s start with a simple example.

def _hello_subrule_impl(ctx, name):
    hello_file = ctx.actions.declare_file(ctx.label.name + name + ".txt")
    return hello_file

hello_subrule = subrule(
    implementation = _hello_subrule_impl,
)

def _hello_impl(ctx):
    hello_file = hello_subrule(name = "Example")

    ctx.actions.write(
        output = hello_file,
        content = "Hello, world!",
    )

    return [
        DefaultInfo(files = depset([hello_file])),
    ]

hello = rule(
    implementation = _hello_impl,
    subrules = [hello_subrule],
)
  1. We define hello_subrule using subrule() and provide its implementation.
  2. The implementation function receives a restricted ctx (a SubruleContext) and any additional parameters.
  3. When defining the hello rule, we must declare subrules = [hello_subrule].
  4. Inside the rule implementation, we call the subrule like a normal function:
   hello_file = hello_subrule(name = "Example")

We do not call it via ctx. The ctx argument is automatically injected.

Subrules may return arbitrary values — not just providers.

Important: Subrules Must Be Declared

If you call a subrule but forget to list it in the subrules = [...] parameter of the rule (or another subrule), Bazel raises a runtime error during analysis.

What You Can and Cannot Do

Subrules can create actions just like rules, but they are intentionally constrained for better encapsulation.

All Attributes Must Be Private

Subrules can declare only implicit dependencies, and their attribute names must begin with an underscore.

This results in an error:

hello_subrule = subrule(
    implementation = _hello_subrule_impl,
    attrs = {
        "compiler": attr.label(),
    },
)
Error in subrule: illegal attribute name 'compiler': subrules may only define private attributes (whose names begin with '_').

Attributes Must Be label or label_list

Subrule attributes may only be attr.label() or attr.label_list().

Other attribute types (e.g., attr.string(), attr.bool()) are not allowed:

attrs = {
    "_compiler": attr.string(),
}

Produces:

Error in subrule: bad type for attribute '_compiler': subrule attributes may only be label or lists of labels.

Private attributes Require Defaults

Because subrule attributes are private implicit dependencies and cannot be set by users of the rule, they must define a default value (or a late-bound default label).

They must be spelled out as implementation function arguments.

The Context Is Restricted

Subrules receive a SubruleContext, not a full RuleContext.

Attempting to access ctx.attr will fail:

def _hello_subrule_impl(ctx, name):
    print(ctx.attr.surname)
    return None
Error: 'subrule_ctx' value has no field or method 'attr'

Available Fields on subrule context:

In the current API, the available members are:

  • actions
  • fragments
  • label
  • toolchains

Toolchains and Execution Groups

Subrules can declare:

subrule(
    implementation = _implementation,
    toolchains = [...],
    exec_group = ...,
    subrules = [...],
)

This allows:

  • Toolchain requirements per subrule
  • Encapsulation of execution groups

Only one exec group per subrule is supported.

Nested Subrules

A subrule may declare and call other subrules by using the subrules = [...] parameter.

Why Subrules Matter

Subrules address several long-standing architectural issues in Starlark rule design:

  • Avoid passing ctx everywhere
  • Encapsulate implicit dependencies
  • Encapsulate toolchain resolution
  • Improve composability
  • Make helper-function patterns more structured

Takeaways

Subrules are a powerful architectural improvement for composing complex Bazel rules, just remember:

  • You must declare subrules using subrules = [...].
  • You call a subrule like a function — not through ctx.
  • ctx is automatically injected.
  • Attributes must be private and label-based.
  • The context is intentionally restricted.
  • Subrules operate only at analysis time.

They are not replacements for existing mechanisms, but they are a much cleaner way to encapsulate reusable rule logic compared to traditional helper functions.

As adoption increases, they will likely become a default way of composing complex rules.