4.1.5 depset vs list

depset is the Starlark data structure for rule data that grows through dependencies: transitive sources, headers, libraries, runfiles, flags, or metadata carried in providers. A list is still the right shape for small data local to one target, but it becomes expensive when every target republishes "my values plus everything below me." A depset is a nested graph with efficient merge operations, so a target can add direct values and point at child depsets instead of copying the whole transitive closure.1

Flattened lists repeat transitive data. Depsets preserve sharing.
Both //app:c and //app:d reach //base:a through //lib:b. Flattened lists repeat that data. Depsets keep shared child links.
List
Each node owns a flattened list
Copies shared closure
//app:d list
d b a
//app:c list
c b a
//lib:b list
b a
//base:a list
a

Shared values are materialized again in each parent list.

Depset
Each node links to child depsets
Preserves shared closure
//app:c depset
direct c
children B
//app:d depset
direct d
children B
//lib:b shared
direct b
children A
//base:a shared
direct a
children none

Parents add direct values and reference existing child depsets.

Lists store the expanded result at every node. Depsets keep a shared graph and flatten only at the consumer boundary.

The Shape Difference

A list stores the flattened result immediately. If target B publishes ["b", "a"], then targets C and D that depend on B each copy B's entries into their own lists. In a small graph that is invisible. In a deep chain, the same values appear again and again. In overlapping top-level binaries or tests, several consumers may each copy a large shared dependency closure.2

A depset stores a node with direct elements and transitive child depsets. The constructor creates a new graph node whose children are existing depsets, so the merge is representation sharing, not list concatenation.3 This is why depset matches the mental model from 2.1 Directed Acyclic Graph (DAG): each rule contributes local facts, then passes a compact transitive view upward.

FilesInfo = provider(fields = {
    "transitive_files": "All files needed by downstream rules.",
})

def _impl(ctx):
    direct_files = ctx.files.srcs
    transitive_files = [
        dep[FilesInfo].transitive_files
        for dep in ctx.attr.deps
    ]

    return [FilesInfo(
        transitive_files = depset(
            direct = direct_files,
            transitive = transitive_files,
        ),
    )]

This is the standard provider pattern: publish bounded rule-local values as lists if useful, but publish graph-propagated values as depsets.4 A typical language-rule provider such as a GoLibraryInfo uses this shape: each library target returns its own metadata plus a depset of dependency metadata, and the linker-like consumer reads the transitive view when it needs the complete set.5 The provider mechanics themselves are expanded in 4.2.7 Custom Provider Declaration. Here the important part is the collection boundary.

Why Lists Go Quadratic

The performance failure is not that lists are slow in isolation. The failure is repeated flattening across the graph. In a dependency chain where each target copies all sources accumulated before it, total analysis work becomes effectively O(n²) for a chain of length n.6 Traversing or copying data accumulated from dependencies is the most common rule performance pitfall, and across a whole build it can easily become O(N²) in time or space.7

The "binary at the top" intuition is also incomplete. Flattening once for one requested terminal target may be acceptable, but a real invocation often builds many overlapping top-level targets, such as a test subtree or an IDE import. If each top-level target expands the same shared depsets into private lists or strings, the overlap recreates quadratic memory use at the boundary.8

This is why the safe default is: keep transitive data as a depset until the API that truly consumes it. If the consuming API accepts depsets, pass the depset directly. ctx.actions.run(inputs = ...) accepts a depset for transitive action inputs, and ctx.actions.args() can hold depsets so command-line expansion is deferred until action execution. That keeps analysis from materializing large intermediate lists.9 The full command-line design belongs in 4.2.3 Args & Command Lines.

Where list Still Belongs

Use a list for data that is local, small, and bounded by the current rule's attributes: this target's direct sources, direct dependency providers, a handful of mode strings, or validation choices. Local lists are fine when they are not publishing transitive dependency information.10

Use a depset when the field name would naturally start with transitive_, all_, or closure_: all headers below this target, all runtime files, all libraries for a linker, all metadata collected by an aspect. Those are graph-shaped facts. They should preserve sharing until the final consumer, not expand at every intermediate node.11

Do not build a depset one element at a time in a loop. That creates deeply nested depsets and can perform poorly. Collect child depsets into a list, then call depset(transitive = children) once, or use a comprehension when the shape is simple.12

# Avoid: repeatedly wraps the previous depset.
files = depset()
for dep in ctx.attr.deps:
    files = depset(transitive = [files, dep[FilesInfo].transitive_files])

# Prefer: merge all transitive children in one constructor call.
files = depset(
    transitive = [
        dep[FilesInfo].transitive_files
        for dep in ctx.attr.deps
    ],
)

Flattening Is A Boundary

to_list() is not forbidden. It is a boundary marker. It copies the depset's contents into a list, suppressing duplicates according to the depset's traversal order.13 Once you call it, downstream code has lost the sharing property that made the depset cheap to propagate.

Good reasons to flatten are narrow: debugging, final validation that genuinely needs list operations, or an external API that only accepts a list and cannot be changed. When the API can consume a depset, prefer that. Action inputs, runfiles helpers, DefaultInfo.files, and Args.add_all() are common places where keeping the depset intact avoids unnecessary analysis memory.14

If you need membership tests, filtering, or set algebra inside one implementation function, a depset is usually the wrong local tool. Depsets are not general hash sets and do not support fast membership tests. Use the Starlark set type where available, or a dictionary-key pattern when compatibility requires it.15 That local collection choice is the boundary with 4.1.6 set (Bazel 8.1+).

Order Is Part Of The Contract

Depsets are not unordered bags. When you construct one, the order parameter controls how to_list() traverses the nested graph. Bazel documents default, postorder, preorder, and topological. default is deterministic but otherwise unspecified.16 Choose an order only when the consuming tool needs one, such as a linker command line where dependency ordering can matter.17

Orders also constrain merging. Two depsets can be merged when they have the same order, or when one uses default. Otherwise the orders are incompatible.18 Pick the order at the provider boundary and keep it consistent through the transitive field.

The mini-ruleset puts this pattern into practice: GlyphInfo declares transitive depset fields, and the manifest rollup composes them without flattening.

key takeaway

Use list for bounded local data. Use depset for transitive data that moves through providers, runfiles, action inputs, or aspect results.

The main rule is not "depset is faster than list." The rule is "do not flatten graph-shaped data until the real consumption boundary." That preserves sharing across the dependency DAG and avoids the quiet O(N²) failure mode that only appears after a rule becomes popular.

Check your understanding · 3 questions

1.Which collection should a rule use for data that accumulates from transitive dependencies?

Select one answer

2.Which practices preserve depset sharing and avoid unnecessary analysis memory?

Select all that apply

3.True or false: depset semantics that matter for rule authors.

Choose True or False for each sentence

A depset is a good general-purpose hash set for fast membership tests.
to_list() copies the depset contents into a flat list.
The order parameter affects traversal when a depset is converted to a list.
Two depsets with incompatible non-default orders can always be merged safely.
0 of 3 answered

Footnotes

  1. Depsets — efficient union operation and direct/transitive construction model.

  2. Optimizing Performance — list representation repeats shared values across dependent nodes.

  3. Depsets — depset constructor creates a DAG node with direct and transitive successors.

  4. Optimizing Performance — local lists are fine, transitive published data should use depsets.

  5. Writing Bazel rules: library rule, depsets, providers — provider carrying a depset of transitive dependency information.

  6. Depsets — list accumulation over a dependency chain creates effectively O(n²) cost.

  7. Optimizing Performance — common O(N²) rule performance pitfall from traversing or copying dependency-accumulated data.

  8. Optimizing Performance — overlapping top-level targets can still make repeated depset flattening quadratic.

  9. Optimizing Performance — pass depsets to ctx.actions.args() and action inputs to defer expansion.

  10. Optimizing Performance — rule-local lists are acceptable when not accumulated from dependencies.

  11. Depsets — depsets should be used when accumulating information through transitive dependencies.

  12. Optimizing Performance — avoid calling depset inside a loop. Merge collected transitive depsets once.

  13. depsetto_list() returns a copy without duplicates in traversal order.

  14. Optimizing Performance — avoid unnecessary flattening. Action inputs and args can accept depsets.

  15. depset — depsets are not general hash sets or fast membership-test structures.

  16. depset — documented traversal orders and deterministic but unspecified default order.

  17. Depsets — tools such as linkers may care about traversal order.

  18. depset — merge compatibility rules for depset orders.