---
title: "Building and packaging a Python library with Bazel"
date: "2025-04-23"
author: "Christian Nunciato"
description: "A step-by-step guide to building, testing, and packaging a Python library with Bazel and the rules_python core library."
tags: "Bazel, Packaging"
readingTime: "13 minute read"
---

# Building and packaging a Python library with Bazel

A step-by-step guide to building, testing, and packaging a Python library with Bazel and the rules_python core library.

<p>If you&apos;re looking for a quick-start guide to building a Python project with Bazel, you&apos;re in the right place. Bazel is a build tool used by many of the world&apos;s largest engineering teams, especially for large-scale multi-language builds. So we thought we&apos;d give you a leg up on learning how to adapt Bazel into your own Python projects and build flows. We&apos;ll walk you through an example of a straightforward project that uses the common patterns for incorporating Bazel into your flow, and explain what we&apos;re doing and why we&apos;re doing it as we go.</p><h2>What is Bazel?</h2><p><a href="https://bazel.build/">Bazel</a> is an open-source build system originally developed by Google. It&apos;s fast and efficient and aims to support multiple programming languages (including Python) in a shared enterprise build, continuous integration (CI), and release environment. It&apos;s particularly popular because of its speed, correctness, and reproducibility.</p><p>Bazel uses <a href="https://github.com/bazelbuild/starlark">Starlark</a>, a domain-specific language for defining builds. If you spend time in a Bazel system, you&apos;ll become familiar with <a href="https://bazel.build/concepts/build-files"><code>BUILD</code> files</a>, which use a declarative system to describe how software should be built. Each piece of software is a build target. Bazel owes its speed (and efficiency) to an action graph that it constructs from the <code>BUILD</code> files. The build system maps dependencies, adds them to the graph, and makes sure only necessary components are built for each target. Bazel provides a sophisticated set of options for configuring and running builds, including for sandboxing, caching, incremental builds, and remote execution.</p><h3><code>WORKSPACE.bazel</code> or <code>MODULE.bazel</code>?</h3><p>Before we dive in too deeply, another thing to know about Bazel is that <a href="https://bazel.build/concepts/build-ref">repository definitions</a> are currently migrating from using <code>WORKSPACE.bazel</code> files to a newer, more declarative model that uses <code>MODULE.bazel</code> files instead. In this walkthrough, we&apos;ll be using the newer <code>MODULE.bazel</code>-based approach.</p><h2>Practical guide: Let&apos;s build a Python project with Bazel</h2><p>Let&apos;s dive right in and build a basic Python app and library with Bazel. (We&apos;ll assume you&apos;re pretty comfortable working with Python, so we can focus on the Bazel-specific aspects.)</p><p>The source code we&apos;re using in this post is up on GitHub, by the way:</p><p><a href="https://github.com/buildkite/bazel-python-package-example">https://github.com/buildkite/bazel-python-package-example</a></p><p>For the purposes of this walkthrough, you can follow along by checking out the code in your local development environment. With the GitHub CLI installed, this command should do it:</p><pre data-language="bash"><code>gh repo clone buildkite/bazel-python-package-example
cd bazel-python-package-example</code></pre><h2>Requirements and configuration</h2><p>If you&apos;d like to follow along and execute or write code as we go, we depend only on Bazel itself. Once installed, it&apos;ll include and manage all of the tools we need for this walkthrough.</p><pre data-language="bash"><code>bazel --version
bazel 7.4.1</code></pre><h3>A note on installing Bazel</h3><p>Rather than installing the Bazel binary directly, we recommend <a href="https://bazel.build/install/bazelisk">installing it with Bazelisk</a>, a user-friendly wrapper for Bazel written in Go that makes managing Bazel installations and builds much easier.</p><p>Bazelisk automatically reads your <code>.bazelversion</code> file (if you have one), finds a suitable version of Bazel, downloads it from the official server if necessary, and then transparently passes through all command-line arguments to the real Bazel binary. If you checked out the source code above, you&apos;ll see in the <code>.bazelversion</code> file in the root of the project that we&apos;re using <code>7.4.1</code> for this example (and you&apos;ll eventually see Bazelisk install that version for you).</p><h3>Wait—don&apos;t I need Python also?</h3><p>Bazel does its best to isolate the build environment, which generally includes making sure all dependencies, like the correct version of Python, are installed and available to builds that require them. You can define a dependency like the one on Python, for example, in the <code>MODULE.bazel</code> file in the root of your repo, as we do in this example. We&apos;re using Python <code>3.11</code>, which you won&apos;t need to install for building—Bazel will manage it automatically as a dependency.</p><p>If you want to, you can bypass the <code>MODULE.bazel</code> settings and configure Bazel to depend on the versions of Python and other tools available on your system, but generally, that&apos;s not recommended. You can read more about <a href="https://bazel.build/external/module">Bazel modules</a> in the official docs.</p><h3>Bazel extensions</h3><p>The <code>MODULE.bazel.lock</code> file in the root directory tracks and locks all of the Bazel extensions on which your project depends. It&apos;s automatically managed by Bazel; you generally don&apos;t need to edit it by hand, but it&apos;s good to know about. Bazel has many stable Python-specific extensions, some of which have been accepted into the <code>rules_python</code> library that we&apos;ll be using.</p><h2>Initialize and set up your project</h2><p>In our project directory, you&apos;ll see an existing pair of <code>.bazelversion</code> and <code>MODULE.bazel</code> files, so if you have Bazelisk installed and you run the <code>bazel</code> command in the root directory of the project, you&apos;ll see Bazel 7.4.1 installing itself:</p><pre data-language="bash"><code>bazel

Extracting Bazel installation...
Starting local Bazel server and connecting to it...
[bazel release 7.4.1]</code></pre><p>If you&apos;re starting from scratch rather than using the sample code, create these files manually:</p><pre data-language="bash"><code>echo &quot;7.4.1&quot; &gt; .bazelversion
touch MODULE.bazel</code></pre><p>Then, you can add some basic definitions into the <code>MODULE.bazel</code> file. Here&apos;s what we&apos;re using:</p><pre data-language="python"><code># MODULE.bazel

bazel_dep(name = &quot;rules_python&quot;, version = &quot;1.0.0&quot;)

pip = use_extension(&quot;@rules_python//python/extensions:pip.bzl&quot;, &quot;pip&quot;)

pip.parse(
    hub_name = &quot;pip&quot;,
    python_version = &quot;3.11&quot;,
    requirements_lock = &quot;//app:requirements_lock.txt&quot;,
)

python = use_extension(&quot;@rules_python//python/extensions:python.bzl&quot;, &quot;python&quot;)

python.toolchain(
    ignore_root_user_error = True,
    is_default = True,
    python_version = &quot;3.11&quot;,
)</code></pre><p>The first line essentially tells Bazel to pull in Bazel support for building projects with Python. We define which versions of Python and pip we&apos;ll be using by referencing the relevant Bazel extensions in two <code>use_extension</code> calls, both of which reference the included <code>rules_python</code> library. These commands tell Bazel to make the pip and Python extensions available for use inside our builds. <code>rules_python</code> is a core library that contains all of the supported Python extensions and build rules for Bazel. Every line that starts with <code>py_</code> in this post references a build rule provided by Bazel.</p><p>At the bottom of the file, we have two calls to <code>use_repo</code> to tell Bazel that we may want to access more than just the <code>pip</code> and <code>python</code> commands from each of the respective extensions:</p><pre data-language="python"><code># MODULE.bazel
# ...

use_repo(pip, &quot;pip&quot;)
use_repo(python, &quot;python_3_11&quot;)</code></pre><h3>Why use <code>use_repo</code>? Won&apos;t the build succeed without it?</h3><p>Bazel is particularly good at guaranteeing build <a href="https://bazel.build/basics/hermeticity">hermeticity</a>. Each Bazel build can be as hermetic as you want, up to and including being fully isolated from the system environment where it&apos;s running. By default, when we use Bazel extensions, we won&apos;t have access to their internals. <code>use_repo</code> grants us access to the build targets from an extension, in this case, some of the internal commands from the pip and Python extensions (and you&apos;ll see us, for example, use the requirement rule from pip farther down: <code>load(&quot;@pip//:requirements.bzl&quot;, &quot;requirement&quot;)</code>).</p><p>A hermetic build, by the way, can also be safely sandboxed, meaning it can be run in an environment with no access to the internet, to your runtime systems, or to specific knowledge of what&apos;s being built on top of it. See the <a href="https://bazel.build/basics/hermeticity">docs</a> for more information.</p><h2>Defining the build targets for a Python library</h2><p>Inside the <code>package</code> directory, we&apos;ve defined a Python library called <code>hello.py</code>. It contains only two lines:</p><pre data-language="python"><code>def say_hi():
    return &quot;Hi!&quot;</code></pre><p>You can double-check that it builds correctly for you, if you&apos;ve checked out our source code, by running the following command:</p><pre data-language="bash"><code>bazel build //package:hello

Starting local Bazel server and connecting to it...
INFO: Analyzed target //package:hello (5 packages loaded, 9 targets configured).
INFO: Found 1 target...
Target //package:hello up-to-date (nothing to build)
INFO: Elapsed time: 3.300s, Critical Path: 0.01s
INFO: 1 process: 1 internal.
INFO: Build completed successfully, 1 total action</code></pre><p>You&apos;ll see that the package is built using the convenient Python building framework <code>hatchling</code>, and has some basic Python library definition details in the <code>pyproject.toml</code> file (all of which will likely be familiar to you already):</p><pre data-language="yaml"><code>[project]
name = &quot;hiworld&quot;
version = &quot;0.0.1&quot;

[build-system]
requires = [&quot;hatchling&quot;]
build-backend = &quot;hatchling.build&quot;</code></pre><p>Bazel knows how to interpret the contents of this file to build with internal dependencies that might be used elsewhere in the project. Bazel builds are defined in one or more <code>BUILD.bazel</code> files—one for each project that gets built. These files are the Bazel equivalents of a Makefile, and Bazel provides a library of build rules that you can use to populate them.</p><p>Let&apos;s first look at how to define a Python library project with a <code>BUILD.bazel</code> file. Below, we call a few Bazel-provided rules to define some targets:</p><pre data-language="python"><code># package/BUILD.bazel

load(&quot;@rules_python//python:defs.bzl&quot;, &quot;py_test&quot;)
load(&quot;@rules_python//python:packaging.bzl&quot;, &quot;py_wheel&quot;, &quot;py_wheel_dist&quot;)

py_library(
    name = &quot;hello&quot;,
    srcs = [&quot;hello.py&quot;],
    visibility = [&quot;//visibility:public&quot;],
    deps = [],
)

py_wheel(
    name = &quot;hello_wheel&quot;,
    distribution = &quot;hello&quot;,
    version = &quot;0.0.1&quot;,
    deps = [],
)

py_wheel_dist(
    name = &quot;hello_wheel_dist&quot;,
    out = &quot;dist&quot;,
    wheel = &quot;:hello_wheel&quot;,
)

py_test(
    name = &quot;hello_test&quot;,
    srcs = [&quot;hello_test.py&quot;],
    deps = [
        &quot;:hello&quot;
    ],
    imports = [&quot;.&quot;],
)</code></pre><p>Bazel automatically transforms the <code>name</code> property for each build target into a build command. Let&apos;s walk through the targets we&apos;re defining for our package, what they do, and how to use them:</p><h3><code>py_library</code></h3><p>Python library build targets are defined with the <code>py_library</code> build rule. (We&apos;ll go into the differences between <code>py_library</code> and <code>py_binary</code>  later.) Bazel executed this rule when you ran <code>bazel build //package:hello</code> earlier. Note that the library is available by default only to Bazel targets in the same Bazel package. By marking the <code>visibility</code> as <code>public</code>, we allow other packages in the repository to see and use it.</p><h3><code>py_test</code></h3><p>This tells Bazel where the test files for this project are located. You can run it from the root directory with <code>bazel test</code> to see the results:</p><pre data-language="bash"><code>bazel test //package:hello_test

INFO: Analyzed target //package:hello_test (1 packages loaded, 2161 targets configured).
INFO: Found 1 test target...
Target //package:hello_test up-to-date:
  bazel-bin/package/hello_test
INFO: Elapsed time: 0.253s, Critical Path: 0.01s
INFO: 1 process: 1 internal.
INFO: Build completed successfully, 1 total action
PASSED: //package:hello_test (see /private/var/tmp/_bazel_cnunciato/91877609f582aac2a59896b10bfc8689/execroot/_main/bazel-out/darwin_arm64-fastbuild/testlogs/package/hello_test/test.log)
INFO: From Testing //package:hello_test
==================== Test output for //package:hello_test:
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK
================================================================================
//package:hello_test                                            (cached) PASSED in 0.7s</code></pre><h3><code>py_wheel</code> and <code>py_wheel_dist</code></h3><p>These two commands conspire to build and store the <a href="https://realpython.com/python-wheels/">Python wheel</a> for this package. You can see that <code>py_wheel_dist</code> references <code>wheel = &quot;:hello_wheel&quot;</code>, the wheel we&apos;re building with <code>py_wheel</code> just above. If you run either of these, you&apos;ll start to see some build artifacts being produced:</p><pre data-language="bash"><code>bazel build //package:hello_wheel_dist

INFO: Analyzed target //package:hello_wheel_dist (2 packages loaded, 36 targets configured).
INFO: Found 1 target...
Target //package:hello_wheel_dist up-to-date:
  bazel-bin/package/dist
INFO: Elapsed time: 1.957s, Critical Path: 1.72s
INFO: 13 processes: 11 internal, 2 darwin-sandbox.
INFO: Build completed successfully, 13 total actions</code></pre><p>You&apos;ll find the resulting build artifacts in <code>bazel-bin:</code></p><pre data-language="bash"><code>ls bazel-bin/package/

dist                                    
hello_wheel_target_wrapped_inputs.txt   
hello_wheel.metadata.txt                
hello_wheel.name                        
hello-0.0.12-py3-none-any.whl</code></pre><p>The wheel itself (by way of <code>py_wheel_dist</code>) is copied to <code>bazel-bin/package/dist</code>:</p><pre data-language="bash"><code>ls bazel-bin/package/dist 

hello-0.0.12-py3-none-any.whl</code></pre><p>Running the build command for <code>hello_wheel_dist</code> also builds all of its dependencies (in this case, <code>hello_wheel</code>), and Bazel will generate both the <code>.whl</code> file and the <code>dist</code> directory.</p><h3>What is <code>bazel-bin</code>—and these other <code>bazel-* directories?</code></h3><p>Each time you run <code>bazel build</code>, Bazel checks to see if you have any cached build artifacts, and only creates them if they&apos;re missing. Typically, you&apos;ll see four directories added to the root of your project after a Bazel build. For example, for our project:</p><ul><li><p>The build environment, at <code>bazel-bazel-python-package-example</code></p></li><li><p>Its build artifacts, at <code>bazel-bin</code> (as referenced above)</p></li><li><p>The output generated during builds, at <code>bazel-out</code> </p></li><li><p>Test-related logs, at <code>bazel-testlogs</code></p></li></ul><p>If you look closer, you&apos;ll see that these directories are all symlinked to a common sandbox elsewhere on your machine; Bazel makes them available to you at the project level for convenience:</p><pre><code>ls -al bazel-*  

bazel-bazel-python-package-example -&gt; /private/var/tmp/_bazel_cnunciato/91877609f582aac2a59896b10bfc8689/execroot/_main
bazel-bin -&gt; /private/var/tmp/_bazel_cnunciato/91877609f582aac2a59896b10bfc8689/execroot/_main/bazel-out/darwin_arm64-fastbuild/bin
bazel-out -&gt; /private/var/tmp/_bazel_cnunciato/91877609f582aac2a59896b10bfc8689/execroot/_main/bazel-out
bazel-testlogs -&gt; /private/var/tmp/_bazel_cnunciato/91877609f582aac2a59896b10bfc8689/execroot/_main/bazel-out/darwin_arm64-fastbuild/testlogs</code></pre><p>Bazel uses these directories to implement caching and incremental builds. Each build target keeps a cache in these directories, which is only updated if relevant changes are detected in the build. If targets depend on each other, only the dependencies that have changed since the last build get rebuilt.</p><h3>What&apos;s the difference between <code>py_library</code> and <code>py_binary</code>?</h3><p><em>Binary</em> and <em>library</em> are terms used by Bazel to keep track of which code should be made executable and which shouldn&apos;t. In the context of Python, where the difference between a library and a directly executable file isn&apos;t as clear as with other languages, Bazel uses the distinction to know which files should be marked as executable at an OS level, and which it should try to execute during testing and for builds.</p><h2>Defining a Python application that uses the library</h2><p>Now that you&apos;ve built a Python library package, you&apos;ll almost surely want to use it in a Python application. In the <code>app</code> directory, you&apos;ll find an example that defines a basic Python app that calls a function from the <code>hello</code> package and uses the same function in a Python test. The app also uses a <code>requirements.txt</code> file to show how to include third-party libraries (here, <code>termcolor</code>, just as an example) in Bazel builds as well. </p><p>Here&apos;s the app&apos;s <code>BUILD.bazel file</code>:</p><pre data-language="python"><code># app/BUILD.bazel

load(&quot;@rules_python//python:defs.bzl&quot;, &quot;py_binary&quot;, &quot;py_test&quot;)
load(&quot;@pip//:requirements.bzl&quot;, &quot;requirement&quot;)
load(&quot;@rules_python//python:pip.bzl&quot;, &quot;compile_pip_requirements&quot;)

py_binary(
    name = &quot;main&quot;,
    srcs = [&quot;main.py&quot;],
    deps = [
        &quot;//package:hello&quot;,
        requirement(&quot;termcolor&quot;),
    ],
)

py_test(
    name = &quot;main_test&quot;,
    srcs = [&quot;main_test.py&quot;],
    deps = [
        &quot;:main&quot;
    ],
    imports = [&quot;.&quot;],
)

compile_pip_requirements(
    name = &quot;requirements&quot;,
    requirements_in = &quot;requirements.txt&quot;,
    requirements_txt = &quot;requirements_lock.txt&quot;,
)</code></pre><p>The commands should be starting to look familiar:</p><h3><code>py_binary</code></h3><p>Just like <code>py_library</code> gives us library build configuration details, here we have instructions on how to build a Python executable program (not literally a binary, as we explained above). You can run it to see the application build:</p><pre data-language="bash"><code>bazel build //app:main

INFO: Analyzed target //app:main (7 packages loaded, 2198 targets configured).
INFO: Found 1 target...
Target //app:main up-to-date:
  bazel-bin/app/main
INFO: Elapsed time: 2.796s, Critical Path: 0.25s
INFO: 5 processes: 5 internal.
INFO: Build completed successfully, 5 total actions</code></pre><h3><code>py_test</code></h3><p>The <code>py_test</code> rule defines a test-running build target and command for the app, similarly to how it defined a target for the library:</p><pre data-language="bash"><code>bazel test //app:main_test

INFO: Analyzed target //app:main_test (1 packages loaded, 2165 targets configured).
INFO: From Testing //app:main_test:
==================== Test output for //app:main_test:
================================================================================
INFO: Found 1 test target...
Target //app:main_test up-to-date:
  bazel-bin/app/main_test
INFO: Elapsed time: 1.314s, Critical Path: 1.01s
INFO: 6 processes: 4 internal, 2 darwin-sandbox.
INFO: Build completed successfully, 6 total actions
//app:main_test                                                          PASSED in 0.6s</code></pre><h3><code>compile_pip_requirements</code></h3><p>This build rule defines a Bazel command to regenerate the <code>requirements.lock</code> file, which ensures that the package&apos;s Python dependencies remain consistent. It&apos;ll be run automatically by Bazel whenever the <code>requirements.txt</code> file changes, but you can also just run it by hand:</p><pre data-language="bash"><code>bazel run //app:requirements.update

INFO: Analyzed target //app:requirements.update (15 packages loaded, 988 targets configured).
INFO: Found 1 target...
Target //app:requirements.update up-to-date:
  bazel-bin/app/requirements.update
INFO: Elapsed time: 0.592s, Critical Path: 0.35s
INFO: 5 processes: 5 internal.
INFO: Build completed successfully, 5 total actions
INFO: Running command line: bazel-bin/app/requirements.update &apos;--src=_main/app/requirements.txt&apos; _main/app/requirements_lock.txt //app:requirements.update &apos;--resolver=backtracking&apos; --allow-unsafe --generate-hashes
Updating app/requirements_lock.txt</code></pre><h2>Putting it all together: building and running the app</h2><p>Of course, once the entire project is defined and wired up, you can build, test, and iterate on it as an entire project, without having to rebuild each target and dependency individually. Bazel ensures that only the targets that have changed are rebuilt from one CLI invocation to the next.</p><p>To build the entire Bazel repository, run <code>bazel build //...</code>:</p><pre data-language="bash"><code>bazel build //...

INFO: Analyzed 9 targets (1 packages loaded, 2187 targets configured).
INFO: Found 9 targets...
INFO: Elapsed time: 0.620s, Critical Path: 0.34s
INFO: 5 processes: 5 internal.
INFO: Build completed successfully, 5 total actions</code></pre><p>To test the entire repository, use <code>bazel test //...</code>:</p><pre data-language="bash"><code>bazel test //...

INFO: Analyzed 9 targets (0 packages loaded, 0 targets configured).
INFO: From Testing //app:requirements_test:
...
INFO: Found 6 targets and 3 test targets...
INFO: Elapsed time: 1.687s, Critical Path: 1.54s
INFO: 2 processes: 2 local.
INFO: Build completed successfully, 2 total actions
...
==================== Test output for //package:hello_test:
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK
================================================================================
//app:main_test                                                 (cached) PASSED in 0.6s
//package:hello_test                                            (cached) PASSED in 0.7s
//app:requirements_test                                                  PASSED in 1.5s</code></pre><p>You can also run the Python application with Bazel directly:</p><pre data-language="bash"><code>bazel run //app:main --ui_event_filters=-INFO --noshow_progress --show_result=0

The Python package says, &apos;Hi!&apos;</code></pre><p>And there you go! You&apos;re now up and running with a fully-functioning (if admittedly fairly simple) Python monorepo managed with Bazel.</p><h2>Next steps</h2><p>You&apos;re now one step closer to expertly navigating the not-so-trivial world of managing Python projects with Bazel. Huzzah!</p><p>Did you know that Buildkite makes working with Bazel even better? With Buildkite, you&apos;re not only able to run Bazel in all of the ways we&apos;ve covered so far, but go several steps farther:</p><ul><li><p>You can upload your Python libraries to <a href="https://buildkite.com/docs/package-registries">Buildkite Package Registries</a>, which has built-in support for PyPI repositories (among many others)</p></li><li><p>You can identify and fix flaky tests with <a href="https://buildkite.com/docs/test-engine">Buildkite Test Engine</a> </p></li><li><p>You can even use Bazel itself, in combination with Python (or any language), to <a href="https://buildkite.com/resources/blog/fully-dynamic-pipelines-with-bazel-and-buildkite/">drive your build and deployment workflows dynamically</a> with <a href="https://buildkite.com/docs/pipelines">Buildkite Pipelines</a></p></li></ul><p>To keep the learning going, we suggest:</p><ul><li><p>Getting familiar with how Bazel makes it easier to inspect, navigate, build, and test complex Bazel projects with <a href="https://buildkite.com/resources/blog/a-guide-to-bazel-query/"><code>bazel query</code></a></p></li><li><p>Kicking the tires with Bazel and Buildkite in tandem with a simple <a href="https://buildkite.com/docs/pipelines/tutorials/bazel">hands-on tutorial</a> </p></li><li><p>Having a look at <a href="https://buildkite.com/resources/webinars/how-bazel-built-its-ci-system-on-buildkite/">how the Bazel team at Google uses Buildkite to ship Bazel</a></p></li></ul><p><span></span></p>