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:
- Multi-line imports with
()continuation if TYPE_CHECKING:blocks (it knows these are type-only imports)if __name__ == "__main__":guards- Comments on the same line as imports (that’s how
# gazelle:ignoreworks)
What it does not handle:
importlib.import_module("foo.bar")— string-based, invisible to static analysis__import__("foo")— same problem- Any form of dynamic import construction
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:
- Should be third-party? Check
modules_mapping. The package name or module name might not match what you expect.import cv2needscv2: opencv_python_headlessin the manifest. If it’s not there, add it. - Should be first-party? Check
python_root. If it’s wrong or unset, gazelle can’t map the import to a target in your repo. - Should be ignored? Use
# gazelle:ignoreon the import line. System-installed packages (like torch in our Docker images) won’t be in the manifest or the RuleIndex.
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:
- Pre-built wheels — package the native code as a wheel with platform-specific
.sofiles, let pip/uv handle it rules_cc+py_extension— teach Bazel to build the C/C++ code and link it into a Python extension- System install — install the native package in your Docker image, use a local Python toolchain (this is what we did for torch)
gazelle:ignore+ manual BUILD — give up on auto-generation, hand-write the deps
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:
- What candidates did gazelle generate? For
from foo.bar import baz, it’s trying[foo.bar.baz, foo.bar, foo] - Is there a
gazelle:resolveoverride active? Walk up the directory tree - Is it in the manifest? Check
gazelle_python.yaml - Is it a first-party target? Check
python_rootconfiguration - Is it in the stdlib list? Usually not the problem, but worth checking for newer modules
- Still stuck? Set
EXPLAIN_DEPENDENCYand let gazelle tell you
Five resolution sources. One priority order. Every gazelle error you’ll ever hit maps to one of these failing.