How do you know a Bazel migration actually works?
Not “the BUILD file looks right.” Not “it built once on my machine.” How do you know — with confidence — that a package migrated to Bazel will build, test, and behave correctly, every time, on any machine?
We migrate packages to Bazel in batches. 5, 10, 20 at a time. Each one has a BUILD file that Gazelle generates, plus manual patches for edge cases. The surface area for mistakes is enormous. A missing dep. A wrong python_root. A test that passes locally because your virtualenv has a package that the Bazel sandbox doesn’t.
So we built a validation tool. The idea is simple and slightly violent: delete everything, regenerate from scratch, and verify the result. If the roundtrip produces a working build, the migration is correct. If it doesn’t, you know exactly where it breaks.
The roundtrip
The tool is bazel_test_migration.py, and it runs in five phases:
1. DELETE → Remove all BUILD/BUILD.bazel from target packages
2. SEED → Create stub BUILD files with python_root
3. GENERATE → Run Gazelle to regenerate everything
4. PATCH → Apply config-driven edge case fixes
5. VERIFY → Fetch → format check → build → test
Each phase is destructive in a useful way. Phase 1 guarantees you’re not relying on hand-edited BUILD artifacts. Phase 3 proves Gazelle can reproduce the build graph from source code alone. Phase 5 proves the result actually works.
Let me walk through each one.
Phase 1: Delete
# For each package in the migration batch:
rm -f packages/foo/BUILD.bazel
rm -f packages/foo/BUILD
rm -f packages/foo/src/BUILD.bazel
rm -f packages/foo/tests/BUILD.bazel
Scorched earth. Every BUILD file in the package tree, gone. This is the “are you sure?” moment — you’re destroying the current build configuration and betting that it can be regenerated.
This matters because hand-edited BUILD files accumulate drift. Someone adds a dep manually, Gazelle doesn’t know about it, and the file becomes a hybrid of generated and manual content. The only way to know if the migration is reproducible is to start from nothing.
Phase 2: Seed
Here’s where the src-layout problem shows up.
Our monorepo uses Python’s src-layout convention. A package called foo lives at packages/foo/src/foo/. The importable code is under src/, not at the package root. This is good practice for avoiding import shadowing, but Gazelle doesn’t handle it automatically.
Without guidance, Gazelle sees packages/foo/src/foo/main.py and doesn’t know that src/ is the Python root — the directory that should be on sys.path. It might try to generate imports like src.foo.main instead of foo.main.
The fix is a stub BUILD file at the src/ level:
# packages/foo/src/BUILD.bazel
python_root()
python_root() tells Gazelle: “This directory is where Python imports start. Everything below here is importable by its relative path.” With this directive, packages/foo/src/foo/main.py correctly becomes import foo.main.
The seeding logic is:
def seed_package(package_path):
# Always seed src/
write_stub(package_path / "src" / "BUILD.bazel")
# Seed tests/ only if it's a separate package root
tests_dir = package_path / "tests"
if tests_dir.exists() and not (tests_dir / "__init__.py").exists():
write_stub(tests_dir / "BUILD.bazel")
The tests/ rule is subtle. If tests/ has an __init__.py, it’s part of the parent package tree — Python treats it as a subpackage of whatever’s above it. No separate root needed. If tests/ has no __init__.py, it’s an implicit namespace package or a standalone test directory. It needs its own python_root() so Gazelle can resolve test imports correctly.
Getting this wrong means Gazelle generates tests that can’t find their imports. The tests build fine (the BUILD file is valid Starlark) but fail at runtime with ModuleNotFoundError. Exactly the kind of bug that a manual inspection wouldn’t catch.
Phase 3: Generate
bazel run //:gazelle -- packages/foo/
Gazelle reads every .py file under the package, extracts imports, classifies them (stdlib, first-party, third-party), resolves third-party imports via the manifest, and generates BUILD files.
After this phase, the package has a complete build graph derived entirely from source code. No human intervention. No hand-edited deps. This is the moment of truth — if Gazelle’s understanding of your code matches reality, the generated BUILD files are correct.
Phase 4: Patch
Reality always has edge cases.
Some packages need tweaks that Gazelle can’t infer. A test that needs GPU tags. A dep that’s resolved via gazelle:resolve but the directive is in a file that was deleted in Phase 1. A visibility rule that crosses package boundaries.
We encode these in a YAML config file — bazel_patch_config.yaml:
packages:
foo:
patches:
- action: add_dep
target: ":foo_test"
dep: "@pypi//pytest-timeout"
- action: set_tag
target: ":foo_gpu_test"
tags: ["torch", "gpu"]
- action: set_visibility
target: ":foo"
visibility: ["//packages/bar:__pkg__"]
The patches are applied via buildozer — a structured BUILD file editor from the Bazel ecosystem. Not sed. Not regex. buildozer understands BUILD syntax, so add_dep actually adds to the deps list without duplicating, set_tag merges with existing tags, and the result is always valid Starlark.
Why not sed? Because BUILD files are code, not text. A sed substitution can break indentation, duplicate entries, corrupt string literals, or silently match the wrong target. Buildozer operates on the AST. It can’t produce invalid BUILD files. The tradeoff is that buildozer’s command syntax is its own little language you have to learn, but the correctness guarantee is worth it.
The config file is also documentation. Every patch entry is a known edge case — a place where Gazelle’s automatic generation needs human knowledge. Over time, as upstream fixes land (better manifest entries, improved import resolution), patches get removed. The config file shrinks. That’s how you measure progress.
Phase 5: Verify
Four commands, in order:
bazel fetch //packages/foo/...
bazel run //:gazelle -- --mode=diff packages/foo/
bazel build //packages/foo/...
bazel test //packages/foo/...
Each one catches different failure modes:
| Command | What it catches |
|---|---|
fetch |
Missing external dependencies, hub resolution failures, network issues |
gazelle --mode=diff |
BUILD file drift — if the diff is non-empty, something changed the BUILD file outside of Gazelle |
build |
Compilation errors, missing deps, import resolution failures |
test |
Runtime errors, missing test fixtures, sandbox isolation issues |
The order matters. fetch must complete before build can start (no packages to build against otherwise). build must pass before test (no point running tests on code that doesn’t compile). And gazelle --mode=diff runs between fetch and build as a sanity check — if the BUILD files have drifted from what Gazelle would generate, something is wrong with the patch config.
A common question: why not bazel run to test binaries? Because bazel build already compiles py_binary targets. The build step verifies that the binary’s dependency graph is complete. Running it would test the binary’s behavior, which is a different concern — that’s for integration tests, not migration validation.
The sufficiency argument
Fetch + format check + build + test. Is that enough?
It is, for migration validation. Here’s why:
- fetch proves that every external dep label resolves to a real package. If
@pypi//pyyamldoesn’t exist in the hub, fetch fails. - format check proves that the BUILD files are canonical — Gazelle would generate the same thing if you ran it again. No drift, no manual edits that Gazelle disagrees with.
- build proves that the dependency graph is complete. Bazel builds in a sandbox — it only sees declared deps. If a dep is missing from the BUILD file, the build fails. This is stricter than
pip install(which sees everything in the virtualenv) orpytest(which sees everything on sys.path). - test proves that the code runs correctly in the Bazel sandbox. A test that passes outside Bazel but fails inside is always a dependency declaration issue — the code needs something that the BUILD file doesn’t declare.
The sandbox is the key. Bazel’s sandbox is what makes this validation actually meaningful. Without sandboxing, a build could “pass” by accidentally finding packages installed globally. With sandboxing, every dependency must be explicit. If the sandboxed build and test pass, the migration is correct by construction.
Running it in practice
For a batch of 20 packages:
python bazel_test_migration.py \
--packages foo bar baz qux ... \
--config bazel_patch_config.yaml \
--verbose
Output is a per-package report:
foo: DELETE ✓ | SEED ✓ | GENERATE ✓ | PATCH ✓ | VERIFY ✓
bar: DELETE ✓ | SEED ✓ | GENERATE ✓ | PATCH ✓ | VERIFY ✗ (test: ImportError torch)
baz: DELETE ✓ | SEED ✓ | GENERATE ✗ (missing python_root for tests/)
Each failure points at the exact phase and the exact error. bar has a torch import that isn’t handled (need a gazelle:resolve directive or a patch). baz needs a test directory seed because its tests/ lacks __init__.py.
Fix, re-run, repeat. The tool is idempotent — running it twice produces the same result. That’s the whole point of delete-and-regenerate: you’re not building on top of previous state. Every run starts clean.
Why delete-and-regenerate works
Most migration validation approaches are additive. “Did the new BUILD file break anything compared to the old one?” That tells you about regressions, not correctness. The old BUILD file might have been wrong in ways that happened to work.
Delete-and-regenerate is constructive. “Can Gazelle produce a correct BUILD file from source code alone?” That tells you whether the migration is reproducible. If you can delete everything, regenerate, and pass all verification steps — the migration isn’t just correct today. It’s correct structurally. Future Gazelle runs will produce the same result.
This is the difference between testing a house by checking that the walls are still standing, versus tearing it down and rebuilding from the blueprints. If the blueprints produce a standing house, the blueprints are good. If they don’t, you know exactly which blueprint to fix.
Violent? Maybe. But you’d rather find out here — in a controlled test with a clear error message — than in CI at 2 AM when someone pushes to main.