Gazelle: Reading the Source Code

2026/03/06

BUILDbazeldependenciesgazellepython
Series: Gazelle (6 posts)
  1. Gazelle: How import yaml Becomes @pypi//pyyaml
  2. Gazelle: Python Manifest and modules_mapping
  3. Gazelle: ASPECT_GAZELLE_CACHE Internals
  4. Gazelle: Python in a Monorepo, the Invisible Machinery
  5. Gazelle: When It Can’t Help, Hand-Writing BUILD Files
  6. Gazelle: Reading the Source Code

I’ve been migrating a ~100 package Python monorepo to Bazel for months now. Gazelle is the thing that makes this even remotely feasible — it reads your imports, generates your BUILD files, and mostly just works. Until it doesn’t.

When it doesn’t, you get an error that says something like “failed to resolve import foo.bar.baz” and your options are: (a) add a gazelle:resolve directive and hope, (b) stare at the manifest YAML, or (c) actually read the source code and figure out what the resolution pipeline is doing.

I got tired of (a) and (b). So I did (c). Here’s what I found.

The cast of characters

Before we get into the code, let’s clear up the ecosystem. Because if you’ve ever googled “gazelle python” you’ve encountered at least five GitHub repos, three of which sound like they do the same thing, and the relationship between them is… not obvious.

Here are the players:

Entity Owner What it is
bazel-gazelle Bazel team (Google) The core framework — plugin API, directory walker, rule merging. Language-agnostic. Gazelle itself knows nothing about Python.
rules_python Bazel team (Google) Python rules for Bazel. Also contains the Python Gazelle extension (gazelle/python/). This is the code we’ll be reading.
aspect-gazelle Aspect Build Pre-built binary that bundles bazel-gazelle + the Python extension + Go/JS plugins. Convenience packaging — no unique source code.
aspect_rules_py Aspect Build Aspect’s alternative/supplemental Python rules. Has its own manifest tooling (gazelle_python_manifest), uv integration.
rules_multitool theoremlp Generic tool for downloading pre-built binaries into Bazel. How aspect-gazelle gets fetched in many setups.

The architecture flows like this:

bazel-gazelle (core framework, plugin API)
    ↓ implements Language interface
rules_python/gazelle/python/ (the Python plugin — resolve.go, generate.go, etc.)
    ↓ bundled into
aspect-gazelle (pre-compiled binary = core + Python + Go + JS plugins)
    ↓ downloaded by
rules_multitool (fetches binary from GitHub releases)
    ↓ used in
your repo's BUILD files

The confusing overlap

There are two Python-for-Bazel ecosystems that partially overlap, and most repos use both:

rules_python (Google) gives you the base py_library, py_test, py_binary rules, the Gazelle Python plugin, and pip.parse() for pip integration.

aspect_rules_py (Aspect Build) extends that with uv integration (uv.declare_hub), uv.lock as lockfile format, and gazelle_python_manifest auto-generation. It supplements rules_python — it doesn’t replace it.

If you’re confused about what’s required versus what’s optional:

Concept Must pick one? Options
Gazelle core Only bazel-gazelle exists No alternative
Python plugin Only rules_python/gazelle/ exists No alternative
Gazelle binary Pre-built OR compile from source aspect-gazelle (pre-built) vs compile the Go code yourself
Python rules rules_python is the base aspect_rules_py extends it
Pip management Many options pip.parse, uv.declare_hub, raw http_archive
Manifest Manual or auto gazelle_python_manifest (Aspect) vs hand-maintain the YAML

The punchline: for the deep-dive we’re about to do, only rules_python matters. Everything else is packaging, delivery, and dependency management flavor. The resolution cascade, the parser, the manifest lookup — that’s all rules_python/gazelle/python/. That’s what we’re reading.

Where the code lives

Gazelle’s Python extension lives in rules_python/gazelle/python/. The files that matter:

File What it does
language.go Entry point — registers the Python language extension
configure.go Reads BUILD directives, loads the manifest
generate.go Walks directories, emits py_library / py_test targets
file_parser.go Tree-sitter parsing of .py files
parser.go Orchestrates parsing + collects annotations
resolve.go The resolution cascade — this is the one
pythonconfig/ Config types, FindThirdPartyDependency
manifest/ YAML parsing for gazelle_python.yaml
modules_mapping/ Python script that introspects .whl files

Most of the “why is gazelle doing this?” questions land in resolve.go. That’s the file that turns import foo.bar.baz into a Bazel label — or fails trying.

The parser: tree-sitter, not regex

First thing I didn’t expect: gazelle uses a real parser. Not regex. Not AST. Tree-sitter — the same incremental parsing framework that powers syntax highlighting in VS Code and Neovim.

It uses go-tree-sitter with the Python grammar to parse every .py file and extract import statements. This matters because it means gazelle handles things like:

What it does not handle:

This is a fundamental constraint, not a bug. Tree-sitter gives you the syntax tree. If the import path is a runtime string, it doesn’t exist in the syntax tree. No static tool can resolve it.

One subtle behavior: from . import X (relative imports within the same package) gets silently dropped. Gazelle assumes same-package imports don’t create cross-target dependencies. This is usually correct — unless you’ve split a package across multiple py_library targets, in which case you’ve got a missing dep that gazelle will never find for you.

The resolution cascade

This is the part I came here for. When gazelle encounters import foo.bar.baz, here’s the exact sequence in resolve.go:

Step 1: Relative import expansion

If the import starts with ., gazelle converts it to an absolute path based on the current file’s location. .utils in mypackage/core/main.py becomes mypackage.core.utils.

Step 2: Candidate generation

For from foo.bar import baz, gazelle doesn’t just try foo.bar.baz. It generates a list of candidates in decreasing specificity:

["foo.bar.baz", "foo.bar", "foo"]

Why? Because baz might be a submodule (a file), or it might be a name defined inside foo/bar/__init__.py. Gazelle doesn’t know which — so it tries the most specific interpretation first and falls back.

Step 3: The priority cascade

For each candidate, gazelle checks these sources in order. First match wins.

a) gazelle:resolve override

The escape hatch. If you’ve put a directive like this in a BUILD file:

# gazelle:resolve py foo.bar //some/package:target

…then any import matching foo.bar immediately resolves to //some/package:target. No further checks. This is the highest priority because it’s the “I know better than you” mechanism.

These directives inherit downward through the directory tree. Put one at the repo root and it applies everywhere. Put it in lib/BUILD.bazel and it applies to lib/ and all subdirectories.

b) Third-party manifest lookup

Next, gazelle checks the modules_mapping in gazelle_python.yaml. This is the lookup table that maps importable module names to pip distribution names:

modules_mapping:
  cv2: opencv_python_headless
  PIL: pillow
  yaml: pyyaml

This mapping is generated by cracking open every .whl file in your dependency set, listing the .py and .so files inside, and converting file paths to dotted module names. cv2/__init__.py inside the opencv-python-headless wheel becomes cv2: opencv_python_headless.

The pain points here are well-documented — I wrote a whole post about the manifest — but the short version: the mapping is static, generated from wheel contents, and anything that doesn’t match a file inside a wheel won’t appear. Private modules (underscore-prefixed) are filtered out. And in our repo, the auto-generator is broken due to an upstream bug, so the whole file is hand-maintained.

c) First-party RuleIndex

Gazelle maintains an index of every py_library target it’s generated (or found) across the repo. If the import path matches a module provided by one of these targets, it resolves to that target.

This is where python_root matters. The python_root directive tells gazelle “this directory is the root for Python imports.” If your repo structure is:

lib/
  mypackage/
    src/
      mypackage/
        core.py

…and python_root is set to lib/mypackage/src, then import mypackage.core resolves correctly. Get python_root wrong and first-party resolution breaks for everything under that directory. You’ll see gazelle try to resolve your own code as a third-party dep — or fail entirely.

One gotcha: if multiple py_library targets provide the same module path, gazelle can’t disambiguate. It’ll either pick one arbitrarily or error, depending on the version. This happens more than you’d think in monorepos with overlapping package structures.

d) Standard library check

Gazelle ships with an embedded stdlib_list.txt — a list of every module in the Python standard library. If the import matches something on this list, gazelle drops the dependency entirely. import os doesn’t become a dep on anything. It just disappears.

This is version-specific. The list corresponds to a particular Python version, and it’s baked into the gazelle binary. If you’re using a Python version that added new stdlib modules (like tomllib in 3.11), make sure your gazelle version knows about them.

e) Validation failure

If none of the above matched — that’s your error. Gazelle can’t resolve the import. If python_validate_import_statements is true (and it should be), gazelle refuses to generate the BUILD file.

The error usually looks like:

failed to resolve import foo.bar.baz

What this actually means: “I checked the resolve directives, the manifest, all first-party targets, and the stdlib list. Nothing matched any of the candidates [foo.bar.baz, foo.bar, foo]. I give up.”

The full picture, as a diagram

import foo.bar.baz
    │
    ▼
Relative import?  ──yes──▶  Expand to absolute path
    │ no                          │
    ▼                             ▼
Generate candidates: [foo.bar.baz, foo.bar, foo]
    │
    ▼  (for each candidate, in order)
    │
    ├─▶ gazelle:resolve override?  ──yes──▶  RESOLVED ✓
    │
    ├─▶ modules_mapping match?     ──yes──▶  RESOLVED ✓
    │
    ├─▶ First-party RuleIndex?     ──yes──▶  RESOLVED ✓
    │
    ├─▶ Standard library?          ──yes──▶  DROP (no dep needed)
    │
    └─▶ None matched               ──────▶  ERROR ✗

That’s it. That’s the whole pipeline. Every “gazelle is being weird” bug I’ve hit maps to one of these stages failing in a way I didn’t expect.

Common failures, mapped to the cascade

Now that you know the pipeline, let’s map real-world pain to specific stages:

“Import not resolved” — all paths exhausted

The error means: every candidate tried every resolution source, nothing matched. The fix depends on which source should have matched:

Gazelle resolves to the wrong target

You have two targets that both provide mypackage.utils. Gazelle picks one. You needed the other.

Fix: gazelle:resolve py mypackage.utils //the/right:target in the closest BUILD file that applies.

Manifest staleness

The modules_mapping was generated from last month’s wheels. You’ve added a new dependency. Its modules aren’t in the mapping. Gazelle doesn’t know about it.

If your auto-generator works: regenerate. If it doesn’t (hello, upstream bugs): add the mapping by hand. pip show <package> tells you the installed location; the importable names are the directory/file names inside it.

python_root misconfiguration

Your repo has lib/foo/src/foo/. You import foo.core. Gazelle looks for a target at lib/foo/src/foo/core.py — but only if python_root is lib/foo/src. If it’s set to lib/foo or unset, gazelle sees foo.core and looks for foo/core.py relative to the wrong root. Nothing matches.

This one’s brutal because the error message doesn’t tell you python_root is the problem. It just says the import can’t be resolved. You have to know to check.

Debug tools you probably didn’t know about

Two environment variables that save hours:

EXPLAIN_DEPENDENCY=<label> — tells gazelle to print exactly how it resolved (or failed to resolve) a specific dependency. Set this to the Bazel label you’re confused about and gazelle will walk you through the cascade step by step.

EXPLAIN_DEPENDENCY=//lib/foo:bar bazel run //:gazelle

RULES_PYTHON_GAZELLE_VERBOSE=1 — enables verbose output for the tree-sitter parser. If gazelle is parsing your imports wrong (maybe it’s seeing a multi-line import as two separate statements, or missing a TYPE_CHECKING guard), this shows you exactly what the parser extracted.

RULES_PYTHON_GAZELLE_VERBOSE=1 bazel run //:gazelle

I’ve spent embarrassing amounts of time staring at YAML files and BUILD files trying to guess what gazelle is thinking. These two flags just… tell you.

The gazelle:resolve inheritance model

One thing that tripped me up repeatedly: gazelle:resolve directives are per-directory and inherit downward. If you put:

# gazelle:resolve py torch @pypi//torch

…in your repo root BUILD file, every directory in your repo will resolve import torch to @pypi//torch. That might be what you want. Or it might break targets that should resolve torch differently (or not at all).

The inheritance is simple — child directories inherit parent directives, and can override them with their own. But when you’re debugging “why is gazelle resolving this import this way?”, you need to walk up the directory tree checking every BUILD file for resolve directives. There’s no “show me all active directives for this directory” command. You just… read the files.

Why the cascade order matters

Here’s the thing that clicked when I read the source: most of my frustration came from not knowing the priority order.

Example: I have import torch. Torch is system-installed, not in the manifest. I add gazelle:resolve py torch @pypi//torch to make gazelle happy. Gazelle is happy. But now @pypi//torch is a dep, and Bazel analysis fails because there’s no torch wheel for my platform in uv.lock.

The fix isn’t a different resolve directive. The fix is understanding that gazelle:resolve (priority a) overrides the manifest lookup (priority b). If I instead add torch: torch to the manifest… that also doesn’t work, because the @pypi//torch package doesn’t exist in my lockfile.

The actual fix is # gazelle:ignore torch on the import — which removes torch from the resolution pipeline entirely — plus @pypi//torch # keep in the BUILD deps with a macro that handles the system-install bridge. The resolution cascade can’t solve a problem where the import shouldn’t be a resolved dependency at all.

Understanding the cascade doesn’t just help you fix problems. It helps you understand which problems can’t be fixed within the cascade, and need a different approach entirely.

The boundary: where Gazelle stops and toolchains start

Everything above is about one problem: turning import foo.bar.baz into a Bazel label. Gazelle is very good at this problem. But there’s a whole category of dependency that Gazelle doesn’t even see.

Native dependencies.

When you import torch, Gazelle can resolve that import — the cascade handles it. But torch isn’t just Python files. It ships .so shared libraries that link against CUDA runtime (libcudart.so), cuDNN, NCCL. Those shared libraries were built by cmake, not setuptools. They need GPU drivers at runtime. None of this exists in a .py file, so none of it exists in Gazelle’s world.

Problem Gazelle helps? Why
import torch → which Bazel label? Yes Resolution cascade
import mai_config → first-party dep? Yes RuleIndex lookup
torch .so files need CUDA at runtime No Native deps, not Python imports
cmake-built C extensions No Gazelle parses .py, not CMakeLists.txt
_C.so from torch needs libcudart.so No Shared library linking

Gazelle parses Python. It runs tree-sitter over .py files, extracts import statements, and maps them to labels. It does not parse CMakeLists.txt. It does not trace shared library linkage. It does not know that torch._C is a compiled extension module that needs a GPU driver to load. That’s a completely different dependency graph — one that lives in BUILD files, Docker images, and toolchain configurations, not in Gazelle’s resolution pipeline.

In our monorepo, this is the wall. lib/bus is a C++/Cython package that blocks ~28 downstream packages from migrating. Gazelle can generate BUILD files for every Python package that depends on lib/bus — it handles the import bus part just fine. But actually building lib/bus requires Cython compilation, C++ linking, and platform-specific shared libraries. Gazelle’s job ended at “this target depends on //lib/bus.” The rest is your problem.

The options when you hit native deps are all outside Gazelle’s jurisdiction:

None of these are Gazelle problems. They’re toolchain and packaging problems. And the most expensive mistake in a Bazel migration isn’t getting the resolution cascade wrong — it’s spending a week trying to make Gazelle solve a native dependency issue that it fundamentally cannot see.

Understanding the cascade makes you fast at migrating Python-only packages. But when you hit native deps, you’ve left Gazelle’s jurisdiction entirely. Knowing where that boundary is saves you from reaching for the wrong tool.

The takeaway

Gazelle’s resolution pipeline is actually elegant. Five sources, checked in priority order, with clear semantics at each stage. The problem isn’t complexity — it’s invisibility. When everything works, you never think about the cascade. When something breaks, you don’t know the cascade exists.

Now you do.

The debugging checklist, in order:

  1. What candidates did gazelle generate? For from foo.bar import baz, it’s trying [foo.bar.baz, foo.bar, foo]
  2. Is there a gazelle:resolve override active? Walk up the directory tree
  3. Is it in the manifest? Check gazelle_python.yaml
  4. Is it a first-party target? Check python_root configuration
  5. Is it in the stdlib list? Usually not the problem, but worth checking for newer modules
  6. Still stuck? Set EXPLAIN_DEPENDENCY and let gazelle tell you

Five resolution sources. One priority order. Every gazelle error you’ll ever hit maps to one of these failing.