How to Build a Local Python Package with uv

python
Author

Martin Řanda

Published

August 3, 2025

A walkthrough of how to build your custom Python package using uv without publishing it.

Taped up box with uv written on the tape

Blink and you’ll miss it. Astral’s uv is a performant package & project manager that’s out to replace all the competing tools.

In this blog post, I’ll walk you through a minimal example of how to build your own Python package without uploading it to the internet. While it’s great to get feedback from others when sharing your code, there may be simple reasons why that’s not always possible. For instance, if you’re building an internal package at work or testing something out, it’s usually not for sharing.

Get started

First, let’s start by installing uv on your machine. Assuming you have Python version 3.8 or later installed, an easy way to install uv is to use pip. First, create a folder for your project (e.g., my-internal-pkg), open up a terminal inside, and create a virtual environment:

python -m venv .venv

Activate the environment and install uv using pip. I’m using PowerShell, so I run the following:

.venv\Scripts\activate
pip install uv

Once uv is installed (v0.8.4 used), initialize your project by running:

uv init

Now, the directory of your project should look like this:

my-internal-pkg/
├── .venv/
├── .gitignore
├── .python-version
├── main.py
├── pyproject.toml
└── README.md

As we can see, uv init created 5 files and initialized a git repository in the project folder. The important file to focus on is pyproject.toml. This file configures your future package based on what you specify inside. Let’s take a peek at the default structure of this file:

[project]
name = "my-internal-pkg"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []

Most of the settings in your pyproject.toml are generated and managed by uv. Of course, all settings in this text file can be modified manually. I would, however, advise against making manual changes except for options like description or when adding whole sections (we’ll touch on that later). Let’s leave these settings unchanged for now.

Package contents

As a minimal example, let me create a simple Python script that calculates rolling minimum and maximum values for a given column in a polars dataframe. If you’re unfamiliar with polars, consider reading my guide on getting started with polars as a pandas user.

First, let me install polars and set it as a dependency for my package:

uv add polars

This command installs the package in the virtual environment created earlier and modifies pyproject.toml by adding polars as a dependency. If we take a look at the pyproject.toml file now, the dependencies setting should look something like this

dependencies = [
    "polars>=1.31.0",
]

Next, let me delete the main.py file since it’s not needed. I’ll then create a folder inside the root directory of my project and name it my_internal_pkg. I’ll also place an empty __init__.py file inside the newly created folder.

My goal is to write a function that calculates rolling min and max values—I’m going to place this function in df_helpers.py. Here’s the updated file tree:

my-internal-pkg/
├── .venv/
├── my_internal_pkg/
   ├── __init__.py
   └── df_helpers.py
├── .gitignore
├── .python-version
├── pyproject.toml
└── README.md

Now onto the contents of df_helpers.py. Keeping it really simple, below is the rolling min max function I placed in this Python script:

import polars as pl


def rolling_min_max(df: pl.DataFrame, col: str, window: int = 3) -> pl.DataFrame:
    """Get rolling min and max values of a specific column"""
    return df.with_columns(
        pl.col(col).rolling_min(window_size=window).alias(f"{col}_roll_min"),
        pl.col(col).rolling_max(window_size=window).alias(f"{col}_roll_max")
    )

Now that we have something to be packaged, we’re almost set. There are some final touches we need to make to the pyproject.toml file.

Finalizing pyprojects.toml

Since this tutorial is aimed at building a package locally, consider adding classifiers = ["Private :: Do Not Upload"] into the [project] section in the config file:

[project]
name = "my-internal-pkg"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "polars>=1.31.0",
]
classifiers = ["Private :: Do Not Upload"]

This labels the package as “private” and adds another layer of defense against accidentally publishing your package to PyPI (see docs).

Next, let’s set up the [build-system] section. uv’s own backend has recently been marked as stable, and it seems reasonable to believe that it’s going to become the default in the future. In the meantime, let me use hatchling from the Python Packaging Authority, which uv authors considered reasonable as a build backed. As a sidenote, I was curious which prominent projects also use this build system: JupyterLab, for example.

To use hatchling as the build backend, we need to place the following section into the .toml file

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

Additionally, we have to specify the folder containing the core of our package (my_internal_pkg/ in our case) because the build system expects to find the src/ folder by default. Many prominent projects like django or pandas don’t follow these defaults either and instead place their scripts into django and pandas subfolders, respectively.

Consequently, we also need to add 1 more section to pyproject.toml:

[tool.hatch.build.targets.wheel]
packages = ["my_internal_pkg/"]

For additional configuration like excluding files/folders in the source distribution, consider taking a look at hatch docs.

We’re almost at the finish line. For completeness, the final pyproject.toml file looks like this:

[project]
name = "my-internal-pkg"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "polars>=1.31.0",
]
classifiers = ["Private :: Do Not Upload"]

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

[tool.hatch.build.targets.wheel]
packages = ["my_internal_pkg/"]

Build

With pyproject.toml set up, we should now be able to build the package. What’s going to happen now is that the build command will create a folder named dist/. Inside this folder, we’re going to see our package in a .tar.gz (source distribution) and .whl (wheel) format. While the difference between these is beyond the scope of this post, if you’re going to be distributing your package locally—to other colleagues or your other systems—consider using the .whl file because it simplifies the process of installing the package. Note that both of these are ultimately archives, so you can take a look inside each to see the differences. To learn more about this topic, I would recommend reading this excellent article on Real Python.

Enough stalling, let’s build the package. Open up a terminal in the root directory of the project, and simply type:

uv build

If successful, the dist/ folder should pop up and the two distributions should be inside:

dist/
├── .gitignore
├── my_internal_pkg-0.1.0.tar.gz
└── my_internal_pkg-0.1.0-py3-none-any.whl

Install

To install the package, grab the .whl file and place it wherever you need it installed. You can then install it using pip (or uv pip) by running:

pip install "path/to/my_internal_pkg-0.1.0-py3-none-any.whl"

That’s it?

Yes, that’s it! With just a few commands and a handful of lines of configuration, we’ve built a Python package from scratch using uv.

You should now be able to import your package in the environment you installed it to using

import my_internal_pkg

This was just a minimal example of how to get started building packages. Consider browsing through repositories of popular Python packages to see how they do it. Additionally, for a tutorial on how to publish your package, take a look at uv docs.

If you have any suggestions or corrections, feel free to let me know in the comments on GitHub!


Comment on GitHub

Reuse

Content of this blogpost is licensed under Creative Commons Attribution CC BY 4.0. Any content used from other sources is indicated and is not covered by this license. (View License)