You have written a library you are proud of. It solves a real problem, the tests pass, and colleagues have asked how to install it. You could point them to a GitHub repo and a README — or you could publish it to PyPI in five minutes and let pip install your-package do the work. The same infrastructure that makes uv fast for managing dependencies also makes it the fastest way to build and publish Python packages. No more juggling setup.py, build, twine, and pip — uv handles the entire pipeline from source to index.
In this tutorial, you will learn to:
- Configure
pyproject.tomlcorrectly for building and publishing Python packages - Build distributable archives (sdist and wheel) with a single
uv buildcommand - Set up TestPyPI for safe publication testing before touching the real index
- Publish to PyPI with token authentication and CI-friendly Trusted Publishing
- Integrate uv into GitHub Actions with proper caching for fast CI runs
- Build lean, production-ready Docker images with
uv sync --no-dev - Wire a complete lint → format → test → build → publish pipeline
Page Contents
Prerequisites
This tutorial builds on the uv getting started guide. You should know how to create a project with uv init, add dependencies, and use uv sync. Familiarity with pyproject.toml structure and a basic understanding of Git and GitHub Actions is assumed.
Configuring pyproject.toml for Publishing
Every publishable Python package needs a pyproject.toml that tells build tools three things: who you are, what your package is, and how to build it. The [project] table provides the metadata that appears on your PyPI page; the [build-system] table tells uv what tool to use when assembling the distribution archive.
The Minimum Publishable pyproject.toml
A library or application that you want to publish needs at minimum these fields:
[project]
name = "my-package"
version = "0.1.0"
description = "A short description of what this package does"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [
{ name = "Your Name", email = "[email protected]" }
]
dependencies = [
"requests>=2.28",
]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
The readme = "README.md" field tells the build system to include your README in the distribution archive — this is what PyPI displays as the project description. The [build-system] table is mandatory: requires lists the build dependencies (setuptools is the most widely supported), and build-backend names the Python object that implements the build process.
Defining Console Scripts with [project.scripts]
If your package provides command-line tools, use the [project.scripts] table to declare them. When users install your package, uv automatically generates wrapper scripts in their bin/ directory.
[project]
name = "my-cli-tool"
version = "0.1.0"
description = "A helpful CLI tool"
requires-python = ">=3.10"
dependencies = []
[project.scripts]
my-cli = "my_cli_tool.__main__:main"
Your package must have a __main__.py file (or module) that exposes a main() callable. When users run pip install my-cli-tool and then type my-cli in their terminal, uv has already generated the wrapper that calls my_cli_tool.__main__.main().
Note: The
[project.scripts]table only works for packages that are installed — not for editable installs during development. Useuv run my-cliduring development to test CLI tools without publishing.
Classifiers and Metadata for Production Packages
PyPI uses classifiers to filter your package by Python version, license, and development status. For a package that is production-ready, add these to your [project] table:
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Software Development :: Libraries :: Python Modules",
]
For a full list of valid classifiers, see the PyPI classifier list.
Building with uv build
Once your pyproject.toml is configured, building a distributable archive takes one command. uv build produces both formats that PyPI accepts: a source distribution (sdist) — a .tar.gz of your source tree — and a wheel (a .whl binary archive with pre-compiled files).
# Build both sdist and wheel (default behaviour)
uv build
# Build only the source distribution
uv build --sdist
# Build only the wheel
uv build --wheel
Both artifacts land in the dist/ directory at your project root:
dist/
├── my-package-0.1.0.tar.gz # source distribution
└── my_package-0.1.0-py3-none-any.whl # wheel (pure Python)
The wheel filename encodes the package name, version, Python ABI tag (py3), and platform tag (none-any for pure Python packages that run on any OS). If your package contains C extensions, the platform tag changes to linux_x86_64, macosx_11_0_arm64, or win_amd64 accordingly.
Tip: Always add
dist/to your.gitignore. These are build artifacts, not source files. The.gitignoregenerated byuv initalready includes this entry.
Publishing to TestPyPI First
Before publishing to the real PyPI, use TestPyPI (test.pypi.org) as a staging ground. Packages published to TestPyPI do not appear in search results and are isolated from production — you can break things safely.
Setting Up TestPyPI Index in pyproject.toml
Add a named index to your pyproject.toml using the [[tool.uv.index]] table:
[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
default = false
[project]
name = "my-package"
version = "0.1.0"
# ...rest of your [project] table
The default = false line means uv uses TestPyPI only when explicitly asked, not for every install. For your normal development workflow, packages still resolve from PyPI by default.
Creating a TestPyPI Account and Getting a Token
Visit test.pypi.org/account/register to create an account. Then go to your account settings → API tokens → Add API token. Give it a descriptive name like testpypi-my-package and copy the token — it looks like pypi-....
Store it as an environment variable in your terminal for testing:
export UV_PUBLISH_TOKEN="pypi-..." # for real PyPI
export UV_TESTPYPI_TOKEN="pypi-..." # for TestPyPI
Publishing to TestPyPI
Publish to TestPyPI using the --index flag to target the named index:
# Build and publish to TestPyPI
uv build
uv publish --index testpypi --token "$UV_TESTPYPI_TOKEN"
Verify the package appears at https://test.pypi.org/project/my-package/. Then install it in a fresh virtual environment to confirm everything works end-to-end:
# Install from TestPyPI to verify
uv pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ my-package
Note: The
--extra-index-url https://pypi.org/simple/is important — it tells pip to fall back to PyPI for your package’s dependencies, since TestPyPI does not mirror every package on PyPI.
Publishing to PyPI
Once your package is verified on TestPyPI, publishing to the real PyPI follows the same pattern — but with real credentials. There are two authentication strategies: API tokens (simple, long-lived) and Trusted Publishing (secure, credential-free, recommended for CI).
Publishing with an API Token
Generate an API token at pypi.org/manage/account/tokens. Scope it to a specific project or your entire account. Then publish:
uv build
uv publish --token "$UV_PUBLISH_TOKEN"
You can also configure PyPI as a named index in pyproject.toml and use --index pypi for symmetry with TestPyPI:
[[tool.uv.index]]
name = "pypi"
url = "https://pypi.org/simple/"
default = true
Trusted Publishing (Recommended for CI)
API tokens are long-lived credentials — if they leak, anyone can publish as you. Trusted Publishing ties publication rights to your GitHub Actions workflow identity using OpenID Connect (OIDC), so there are no secrets to manage at all.
To set up Trusted Publishing for your package on PyPI:
- Go to pypi.org/manage/account/publishing/
- Click Add a new pending publisher
- Fill in your GitHub repository URL, workflow filename (e.g.,
publish.yml), and environment name (e.g.,production) - Save — PyPI now trusts any workflow run from that path
In your GitHub Actions workflow, uv publish automatically detects the OIDC token in GitHub Actions and uses Trusted Publishing — no --token flag needed:
- name: Publish to PyPI
env:
UV_PUBLISH_TOKEN: ${{ secrets.OIDC_TOKEN }} # GitHub provides this automatically
run: uv publish --index pypi
Security note: Trusted Publishing is supported for PyPI but not yet for TestPyPI. For TestPyPI publication in CI, you still need an API token stored as a GitHub Secret.
uv in GitHub Actions
The astral-sh/setup-uv GitHub Action is the official way to install uv in a workflow. It installs uv, caches it between runs, and optionally sets up a specific Python version.
Installing uv with setup-uv
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true # cache uv itself between runs
- name: Install Python
run: uv python install 3.12
- name: Install dependencies
run: uv sync --frozen
- name: Run tests
run: uv run pytest
Caching the Virtual Environment for Speed
The enable-cache: true option in setup-uv caches the uv binary itself. To also cache the .venv dependency directory between runs, use the actions/cache step with the lockfile hash as the cache key:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- name: Install Python
run: uv python install 3.12
- name: Cache virtual environment
uses: actions/cache@v4
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
- name: Install dependencies
run: uv sync --frozen
- name: Run tests
run: uv run pytest
The cache key uses uv.lock‘s hash, so the cache is automatically invalidated whenever a dependency changes. With --frozen, uv sync installs exactly what uv.lock specifies without attempting to update it — fast, reproducible, and cache-friendly.
uv + Ruff + pytest: A Complete Dev Pipeline
A production-quality Python project needs at least three automated checks before any code reaches production: linting, formatting, and testing. With uv, each step is a one-liner and they all run through the same uv run interface. For a complete walkthrough of setting up a Python project with uv from scratch — including initializing the repo, adding dependencies, and wiring GitHub Actions — see the uv project setup guide.
Adding Dev Dependencies
Dev dependencies — tools used during development but not shipped — go in the [dependency-groups.dev] table (PEP 735). These are never installed in production.
uv add --dev ruff pytest pytest-cov
This adds the following to your pyproject.toml:
[dependency-groups]
dev = [
"ruff>=0.9.0",
"pytest>=8.0",
"pytest-cov>=6.0",
]
Wiring the Pipeline in GitHub Actions
name: CI
on: [push, pull_request]
jobs:
quality-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with: { enable-cache: true }
- run: uv sync --frozen --all-groups
- name: Lint with Ruff
run: uv run ruff check .
- name: Format check with Ruff
run: uv run ruff format --check .
- name: Run tests with coverage
run: uv run pytest --cov=src --cov-report=xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./coverage.xml
Notice --all-groups — without it, uv sync installs only the main dependencies, skipping the dev group. For CI, --all-groups ensures linting and testing tools are available.
uv + Docker: Lean Production Images
For production deployments, your Docker image should contain only the code and runtime dependencies needed to run the application — not your entire dev environment. uv makes this straightforward with the --no-dev flag. For a deeper dive into Docker patterns for Python applications — including multi-stage builds, non-root users, and security hardening — see the Python production security guide which covers containerised deployments in detail.
Minimal Dockerfile
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS base
# Install uv into the base image
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
# Copy lockfile and manifest, then sync dependencies
# --no-dev excludes dev-only packages (linters, pytest, etc.)
COPY uv.lock pyproject.toml ./
RUN uv sync --frozen --no-dev --no-install-project
# Copy application source
COPY . .
# Install the project itself into the venv
RUN uv sync --frozen --no-dev
The two-step uv sync pattern is deliberate. The first sync installs all dependencies from the lockfile into the shared venv. The second sync installs your local project package on top. Separating these means that dependency changes do not invalidate the Docker layer that copies your source code, preserving the build cache.
Why
python:3.12-slim? This image has no uv and no pre-installed packages — you control everything. Theslimvariant keeps the image small (~150MB vs ~900MB for the full image). If you need the uv Python interpreter embedded, useghcr.io/astral-sh/uv:latestas your base instead.
Multi-Stage Build for Even Smaller Images
For the smallest possible production image, use a multi-stage build that copies only the installed venv into a fresh runtime image:
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY uv.lock pyproject.toml ./
RUN uv sync --frozen --no-dev --no-install-project --fetch-python
COPY . .
RUN uv sync --frozen --no-dev
# Runtime stage — copies only the installed venv and source
FROM python:3.12-slim AS runtime
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/src /app/src
ENV PATH="/app/.venv/bin:$PATH"
CMD ["python", "-m", "my_package"]
The runtime image is smaller because it contains only the final installed state — no build tools, no uv, no cache directories. The ENV PATH line ensures Python and any console scripts you defined in [project.scripts] are on the PATH.
Practical GitHub Actions Template
Here is a complete GitHub Actions workflow that combines everything in this article — lint → test → build → publish. It covers both the standard CI checks and a release trigger for publishing.
name: Release
on:
push:
tags:
- "v*" # trigger on git tags like v0.1.0
pull_request: {}
env:
UV_CACHE_DIR: .uv-cache
jobs:
# ──────────────── Continuous Integration ───────────────────────────────────
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: Set up Python
run: uv python install 3.12
- name: Install all dependencies
run: uv sync --frozen --all-groups
- name: Lint
run: uv run ruff check .
- name: Format check
run: uv run ruff format --check .
- name: Type check
run: uv run mypy src/
- name: Test
run: uv run pytest --cov=src --cov-report=xml
- name: Build distribution
run: uv build
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
# ──────────────── Publish to PyPI (on tagged release) ──────────────────────
publish:
needs: ci
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
environment:
name: pypi
url: https://pypi.org/project/my-package/
permissions:
id-token: write # required for Trusted Publishing
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Publish to PyPI
run: uv publish --index pypi
This template has two jobs: ci runs on every push and pull request, and publish runs only on version tags (v0.1.0, v1.2.3, etc.) after the CI checks pass. The needs: ci dependency ensures tests pass before any publication happens.
Common Mistakes / Gotchas
- Publishing to the wrong index: Running
uv publishwithout--indexsends your package to PyPI by default, even if you intended TestPyPI. Always double-check the index flag when switching between environments. - Accidentally stripping test tools in production:
uv sync --no-devexcludes the entiredevdependency group — including pytest, Ruff, and mypy. If your tests or type checks run as part of CI before the--no-devbuild, this is fine. If you rely on dev tools in production containers, move them to the maindependencieslist instead. - Trusted Publishing OIDC misconfiguration: The
id-token: writepermission is required in the GitHub Actions job for Trusted Publishing to work. Without it,uv publishsilently falls back to unauthenticated upload — the job appears to succeed but PyPI rejects the package. - Forgetting
dist/in.gitignore: Build artifacts indist/are not source files. If you commit them, every future build will include stale artifacts. The.gitignoregenerated byuv inithandles this automatically, but manually created.gitignorefiles often miss it.
Summary and Next Steps
uv is not just a faster pip — it is a complete end-to-end tool for the Python package lifecycle. From initial uv init through development (uv sync), linting and testing (via uv run), building (uv build), and finally publishing (uv publish), every step integrates with the same tool and the same pyproject.toml configuration.
- Add
[build-system]and console script declarations to yourpyproject.tomlas the first step toward publishing - Always test on TestPyPI before touching the real index — the workflow is identical, the stakes are not
- Use Trusted Publishing for CI — no secrets, no rotation, no leakage
- Cache your
.venvin CI using theuv.lockhash as the key — it pays off immediately on the second run - Use
--no-devin production Docker images to keep them lean and attack-surface minimal
For a deeper dive into uv’s capabilities, see the official building and publishing guide on Astral Docs and the GitHub Actions integration guide.
Frequently Asked Questions
Can I publish a package without a GitHub repository?
Yes. PyPI does not require a GitHub link. However, Trusted Publishing (the secure, token-free CI publishing method) requires a GitHub Actions workflow. If you do not use GitHub, you can still publish using an API token — store it as a secret in your CI system (GitLab CI, Bitbucket Pipelines, etc.) and pass it to uv publish --token.
What is the difference between uv build and python -m build?
python -m build is the reference build frontend from the build package — it installs build dependencies into an isolated environment and calls the backend. uv build is native and significantly faster; it does not create an isolated environment, uses your existing venv, and handles both source distributions and wheels in one command. For publishing workflows, uv build is the recommended tool.
Can I use uv publish with a private package index?
Yes. Add your private index as a named [[tool.uv.index]] in pyproject.toml and use uv publish --index <name> to target it. Many teams use Artifactory, GitHub Package Registry, or a self-hosted PyPI proxy to cache and control access to packages in enterprise environments.

