1. Resources
  2. /
  3. Blog
  4. /
  5. Building and packaging a Python library with Bazel

Building and packaging a Python library with Bazel

13 minute read

A stylized representation of the Bazel and Python logos in a Tetris-like configuration.

If you're looking for a quick-start guide to building a Python project with Bazel, you're in the right place. Bazel is a build tool used by many of the world's largest engineering teams, especially for large-scale multi-language builds. So we thought we'd give you a leg up on learning how to adapt Bazel into your own Python projects and build flows. We'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're doing and why we're doing it as we go.

What is Bazel?

Bazel is an open-source build system originally developed by Google. It'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's particularly popular because of its speed, correctness, and reproducibility.

Bazel uses Starlark, a domain-specific language for defining builds. If you spend time in a Bazel system, you'll become familiar with BUILD files, 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 BUILD 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.

WORKSPACE.bazel or MODULE.bazel?

Before we dive in too deeply, another thing to know about Bazel is that repository definitions are currently migrating from using WORKSPACE.bazel files to a newer, more declarative model that uses MODULE.bazel files instead. In this walkthrough, we'll be using the newer MODULE.bazel-based approach.

Practical guide: Let's build a Python project with Bazel

Let's dive right in and build a basic Python app and library with Bazel. (We'll assume you're pretty comfortable working with Python, so we can focus on the Bazel-specific aspects.)

The source code we're using in this post is up on GitHub, by the way:

https://github.com/buildkite/bazel-python-package-example

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:

gh repo clone buildkite/bazel-python-package-example
cd bazel-python-package-example

Requirements and configuration

If you'd like to follow along and execute or write code as we go, we depend only on Bazel itself. Once installed, it'll include and manage all of the tools we need for this walkthrough.

bazel --version
bazel 7.4.1

A note on installing Bazel

Rather than installing the Bazel binary directly, we recommend installing it with Bazelisk, a user-friendly wrapper for Bazel written in Go that makes managing Bazel installations and builds much easier.

Bazelisk automatically reads your .bazelversion 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'll see in the .bazelversion file in the root of the project that we're using 7.4.1 for this example (and you'll eventually see Bazelisk install that version for you).

Wait—don't I need Python also?

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 MODULE.bazel file in the root of your repo, as we do in this example. We're using Python 3.11, which you won't need to install for building—Bazel will manage it automatically as a dependency.

If you want to, you can bypass the MODULE.bazel settings and configure Bazel to depend on the versions of Python and other tools available on your system, but generally, that's not recommended. You can read more about Bazel modules in the official docs.

Bazel extensions

The MODULE.bazel.lock file in the root directory tracks and locks all of the Bazel extensions on which your project depends. It's automatically managed by Bazel; you generally don't need to edit it by hand, but it's good to know about. Bazel has many stable Python-specific extensions, some of which have been accepted into the rules_python library that we'll be using.

Initialize and set up your project

In our project directory, you'll see an existing pair of .bazelversion and MODULE.bazel files, so if you have Bazelisk installed and you run the bazel command in the root directory of the project, you'll see Bazel 7.4.1 installing itself:

bazel

Extracting Bazel installation...
Starting local Bazel server and connecting to it...
[bazel release 7.4.1]

If you're starting from scratch rather than using the sample code, create these files manually:

echo "7.4.1" > .bazelversion
touch MODULE.bazel

Then, you can add some basic definitions into the MODULE.bazel file. Here's what we're using:

# MODULE.bazel

bazel_dep(name = "rules_python", version = "1.0.0")

pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip")

pip.parse(
    hub_name = "pip",
    python_version = "3.11",
    requirements_lock = "//app:requirements_lock.txt",
)

python = use_extension("@rules_python//python/extensions:python.bzl", "python")

python.toolchain(
    ignore_root_user_error = True,
    is_default = True,
    python_version = "3.11",
)

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'll be using by referencing the relevant Bazel extensions in two use_extension calls, both of which reference the included rules_python library. These commands tell Bazel to make the pip and Python extensions available for use inside our builds. rules_python is a core library that contains all of the supported Python extensions and build rules for Bazel. Every line that starts with py_ in this post references a build rule provided by Bazel.

At the bottom of the file, we have two calls to use_repo to tell Bazel that we may want to access more than just the pip and python commands from each of the respective extensions:

# MODULE.bazel
# ...

use_repo(pip, "pip")
use_repo(python, "python_3_11")

Why use use_repo? Won't the build succeed without it?

Bazel is particularly good at guaranteeing build hermeticity. Each Bazel build can be as hermetic as you want, up to and including being fully isolated from the system environment where it's running. By default, when we use Bazel extensions, we won't have access to their internals. use_repo 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'll see us, for example, use the requirement rule from pip farther down: load("@pip//:requirements.bzl", "requirement")).

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's being built on top of it. See the docs for more information.

Defining the build targets for a Python library

Inside the package directory, we've defined a Python library called hello.py. It contains only two lines:

def say_hi():
    return "Hi!"

You can double-check that it builds correctly for you, if you've checked out our source code, by running the following command:

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

You'll see that the package is built using the convenient Python building framework hatchling, and has some basic Python library definition details in the pyproject.toml file (all of which will likely be familiar to you already):

[project]
name = "hiworld"
version = "0.0.1"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

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 BUILD.bazel 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.

Let's first look at how to define a Python library project with a BUILD.bazel file. Below, we call a few Bazel-provided rules to define some targets:

# package/BUILD.bazel

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

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

py_wheel(
    name = "hello_wheel",
    distribution = "hello",
    version = "0.0.1",
    deps = [],
)

py_wheel_dist(
    name = "hello_wheel_dist",
    out = "dist",
    wheel = ":hello_wheel",
)

py_test(
    name = "hello_test",
    srcs = ["hello_test.py"],
    deps = [
        ":hello"
    ],
    imports = ["."],
)

Bazel automatically transforms the name property for each build target into a build command. Let's walk through the targets we're defining for our package, what they do, and how to use them:

py_library

Python library build targets are defined with the py_library build rule. (We'll go into the differences between py_library and py_binary later.) Bazel executed this rule when you ran bazel build //package:hello earlier. Note that the library is available by default only to Bazel targets in the same Bazel package. By marking the visibility as public, we allow other packages in the repository to see and use it.

py_test

This tells Bazel where the test files for this project are located. You can run it from the root directory with bazel test to see the results:

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

py_wheel and py_wheel_dist

These two commands conspire to build and store the Python wheel for this package. You can see that py_wheel_dist references wheel = ":hello_wheel", the wheel we're building with py_wheel just above. If you run either of these, you'll start to see some build artifacts being produced:

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

You'll find the resulting build artifacts in bazel-bin:

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

The wheel itself (by way of py_wheel_dist) is copied to bazel-bin/package/dist:

ls bazel-bin/package/dist 

hello-0.0.12-py3-none-any.whl

Running the build command for hello_wheel_dist also builds all of its dependencies (in this case, hello_wheel), and Bazel will generate both the .whl file and the dist directory.

What is bazel-bin—and these other bazel-* directories?

Each time you run bazel build, Bazel checks to see if you have any cached build artifacts, and only creates them if they're missing. Typically, you'll see four directories added to the root of your project after a Bazel build. For example, for our project:

  • The build environment, at bazel-bazel-python-package-example
  • Its build artifacts, at bazel-bin (as referenced above)
  • The output generated during builds, at bazel-out
  • Test-related logs, at bazel-testlogs

If you look closer, you'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:

ls -al bazel-*  

bazel-bazel-python-package-example -> /private/var/tmp/_bazel_cnunciato/91877609f582aac2a59896b10bfc8689/execroot/_main
bazel-bin -> /private/var/tmp/_bazel_cnunciato/91877609f582aac2a59896b10bfc8689/execroot/_main/bazel-out/darwin_arm64-fastbuild/bin
bazel-out -> /private/var/tmp/_bazel_cnunciato/91877609f582aac2a59896b10bfc8689/execroot/_main/bazel-out
bazel-testlogs -> /private/var/tmp/_bazel_cnunciato/91877609f582aac2a59896b10bfc8689/execroot/_main/bazel-out/darwin_arm64-fastbuild/testlogs

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.

What's the difference between py_library and py_binary?

Binary and library are terms used by Bazel to keep track of which code should be made executable and which shouldn't. In the context of Python, where the difference between a library and a directly executable file isn'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.

Defining a Python application that uses the library

Now that you've built a Python library package, you'll almost surely want to use it in a Python application. In the app directory, you'll find an example that defines a basic Python app that calls a function from the hello package and uses the same function in a Python test. The app also uses a requirements.txt file to show how to include third-party libraries (here, termcolor, just as an example) in Bazel builds as well.

Here's the app's BUILD.bazel file:

# app/BUILD.bazel

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

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

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

compile_pip_requirements(
    name = "requirements",
    requirements_in = "requirements.txt",
    requirements_txt = "requirements_lock.txt",
)

The commands should be starting to look familiar:

py_binary

Just like py_library 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:

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

py_test

The py_test rule defines a test-running build target and command for the app, similarly to how it defined a target for the library:

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

compile_pip_requirements

This build rule defines a Bazel command to regenerate the requirements.lock file, which ensures that the package's Python dependencies remain consistent. It'll be run automatically by Bazel whenever the requirements.txt file changes, but you can also just run it by hand:

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 '--src=_main/app/requirements.txt' _main/app/requirements_lock.txt //app:requirements.update '--resolver=backtracking' --allow-unsafe --generate-hashes
Updating app/requirements_lock.txt

Putting it all together: building and running the app

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.

To build the entire Bazel repository, run bazel build //...:

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

To test the entire repository, use bazel test //...:

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

You can also run the Python application with Bazel directly:

bazel run //app:main --ui_event_filters=-INFO --noshow_progress --show_result=0

The Python package says, 'Hi!'

And there you go! You're now up and running with a fully-functioning (if admittedly fairly simple) Python monorepo managed with Bazel.

Next steps

You're now one step closer to expertly navigating the not-so-trivial world of managing Python projects with Bazel. Huzzah!

Did you know that Buildkite makes working with Bazel even better? With Buildkite, you're not only able to run Bazel in all of the ways we've covered so far, but go several steps farther:

To keep the learning going, we suggest:

How Bazel built its CI system on Buildkite

Register to watch the webinar

How Bazel built its CI system on Buildkite
Recorded on
March 17, 2025
Length
19 minutes

Related posts

Start turning complexity into an advantage

Create an account to get started with a 30-day free trial. No credit card required.

Buildkite Pipelines

Platform

  1. Pipelines
  2. Pipeline templates
  3. Public pipelines
  4. Test Engine
  5. Package Registries
  6. Mobile Delivery Cloud
  7. Pricing

Hosting options

  1. Self-hosted agents
  2. Mac hosted agents
  3. Linux hosted agents

Resources

  1. Docs
  2. Blog
  3. Changelog
  4. Webinars
  5. Plugins
  6. Case studies
  7. Events
  8. Comparisons

Company

  1. About
  2. Careers
  3. Press
  4. Brand assets
  5. Contact

Solutions

  1. Replace Jenkins
  2. Workflows for AI/ML
  3. Testing at scale
  4. Monorepo mojo
  5. Bazel orchestration

Legal

  1. Terms of Service
  2. Acceptable Use Policy
  3. Privacy Policy
  4. Subprocessors
  5. Service Level Agreement

Support

  1. System status
  2. Forum
© Buildkite Pty Ltd 2025