How to add dynamic versioning to uv projects
Dynamic versioning generates version numbers from Git tags instead of requiring manual updates to a static version string in pyproject.toml. The release workflow becomes a single git tag command: the build backend reads the tag, and the wheel and source distribution filenames pick up the matching version automatically.
This guide uses uv-dynamic-versioning, a hatchling plugin that ships a sensible default configuration for uv projects.
Does uv support dynamic versioning natively?
Not from Git tags. uv’s built-in uv version --bump patch (also minor and major) edits the static version string in pyproject.toml, so you still commit a version bump on every release. uv’s default uv_build backend requires that static field and rejects a dynamic = ["version"] project at build time with missing field version. Reading the version from Git tags needs a hatchling plugin, which the rest of this guide configures. Build and metadata hooks in uv’s own backend, the mechanism a VCS version plugin would hook into, are an open request in astral-sh/uv#14561.
Prerequisites
- A Git repository for your Python project with at least one commit
- uv installed on your system
- A
src/layout holding the package directory (uv init --packagecreates one)
Configure the build system
Update pyproject.toml to use uv-dynamic-versioning as a build backend dependency:
[build-system]
requires = ["hatchling", "uv-dynamic-versioning"]
build-backend = "hatchling.build"Note
This sets hatchling as the build backend, not uv’s default uv_build. uv-dynamic-versioning is a hatchling plugin, and its README states it “doesn’t work with the uv build backend right now.” Building a dynamic = ["version"] project with uv_build fails with missing field version, so switching the backend to hatchling is the supported path for tag-driven versioning. See Why did uv originally use Hatch as a build backend?.
Set the version source
Mark the version field as dynamic and point hatchling at uv-dynamic-versioning:
[project]
name = "your-project"
dynamic = ["version"] # Remove any static version = "..." line
[tool.hatch.version]
source = "uv-dynamic-versioning"
[tool.hatch.build.targets.wheel]
packages = ["src/your_package"] # hatchling ships this directory[tool.hatch.build.targets.wheel] names the directory hatchling puts in the wheel, a table uv_build never asked for. Without it hatchling hunts for a directory matching the normalized project name, so a project named your-project living in src/your_package/ stops the build:
$ uv build
ValueError: Unable to determine which files to ship inside the wheel using the following heuristics: https://hatch.pypa.io/latest/plugins/builder/wheel/#default-file-selection
The most likely cause of this is that there is no directory that matches the name of your project (your_project).
At least one file selection option must be defined in the `tool.hatch.build.targets.wheel` table
Naming the directory explicitly makes the build work whatever the project is called. A project whose directory already matches its name, which is what uv init --package your-project produces in src/your_project/, builds without the table.
Create a Git tag
Tag a commit following the default pattern (a v prefix followed by a semantic version):
$ git tag v0.1.0
Build and verify
$ uv build
The built distribution’s filename includes the version derived from the tag, for example your_project-0.1.0-py3-none-any.whl.
Understand versions between tags
When the working tree is tagged exactly, the version is clean (0.1.0). When you build from a commit past the most recent tag, uv-dynamic-versioning appends a PEP 440 post-release and dev segment plus the commit hash as a local identifier:
your_project-0.1.0.post1.dev0+91cc190-py3-none-any.whlThe post1 counts commits since the tag and +91cc190 is the short commit hash. These development versions sort higher than the last release but lower than the next tagged release, so uv pip install . on a feature branch installs a version that supersedes 0.1.0 without claiming to be 0.2.0. PyPI rejects local version identifiers (anything after +), so only clean tagged builds can be uploaded.
Choose a version style and tag pattern
uv-dynamic-versioning computes versions with dunamai, the engine shared by several VCS-versioning tools. Configure its output under [tool.uv-dynamic-versioning]:
[tool.uv-dynamic-versioning]
style = "pep440" # also "semver" or "pvp"
pattern = "default" # "default" requires a "v" prefix; "default-unprefixed" drops itstyleselects the version scheme:pep440(the default),semver, orpvp. Thepep440default produces0.1.0.post1.dev0+91cc190. Wheel and sdist filenames are normalized to PEP 440 regardless, so keeppep440unless another tool reads the raw version string.patterndecides which tags count as releases.defaultmatchesv1.2.3;default-unprefixedmatches1.2.3.- The commit hash carries no prefix by default. Set
commit-prefix = "g"for thegit describe-style+g91cc190.
Expose the version at runtime
To make the version accessible within the package:
# src/your_package/__init__.py
import importlib.metadata
try:
__version__ = importlib.metadata.version("your-project") # the [project] name
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for an uninstalled source treeThis reads the version from installed package metadata, so it stays in sync with the Git tag without duplicating the value.
Pass the distribution name, not __name__. __name__ is the module name, so unless it happens to match the normalized project name, importlib.metadata.version(__name__) raises PackageNotFoundError in a correctly installed package and the except pins __version__ to "0.0.0" with no error.
Automate tag-to-publish releases with GitHub Actions
Tag-driven versioning pairs with a workflow that triggers on the tag itself: push v0.2.0, and CI builds the distribution with that exact version and publishes it. Create .github/workflows/release.yml:
name: Release
on:
push:
tags: ["v*"]
jobs:
release:
runs-on: ubuntu-latest
permissions:
id-token: write # OIDC token for trusted publishing to PyPI
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # full history and tags so the tag is present
- uses: astral-sh/setup-uv@v9.0.0
- run: uv build
- run: uv publishfetch-depth: 0 is the line specific to dynamic versioning. actions/checkout does a shallow clone by default, and uv-dynamic-versioning derives the version from Git history. Without the full history and tags, the build still succeeds, but it publishes a 0.0.0-based development version (for example 0.0.0.post1.dev0+91cc190) instead of the 0.2.0 you tagged.
uv publish uploads via trusted publishing when the job has id-token: write, so no PyPI API token lives in CI. Configure the PyPI side once by adding your repository and release.yml as a trusted publisher, which that guide walks through. To gate releases behind a manual GitHub Release instead of a raw tag push, swap the trigger for on: release: types: [published].
With the workflow committed, cutting a release is two commands:
$ git tag v0.2.0
$ git push origin v0.2.0
The pushed tag triggers the workflow, which builds 0.2.0 and publishes it.
Build where Git is unavailable
Some builds run with no repository at all: an unpacked source distribution, or a Docker build whose COPY never brings in .git. uv-dynamic-versioning has nothing to read and the build stops:
$ uv build
RuntimeError: Error getting the version from source `uv-dynamic-versioning`: Unable to detect version control system. Checked: Git. Not installed: Mercurial, Darcs, Subversion, Bazaar, Fossil, Pijul.
Define a fallback so those builds still produce a wheel:
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"The fallback covers only the missing-repository case. A shallow clone that keeps .git but drops the tags already builds without complaint, at 0.0.0.post1.dev0+<commit>, and fallback-version does not change that result. Fix tagless checkouts with fetch-depth: 0 instead.
Frequently asked questions
Do I still need to edit pyproject.toml on every release?
No. The whole point of dynamic versioning is that version is no longer a static string. The only file that changes on a release is the Git tag.
Should I use this or uv’s uv version command?
They solve different problems. uv version --bump patch (also major and minor) edits the static version string in pyproject.toml, so the file stays the source of truth and the tag is created afterward. uv-dynamic-versioning inverts that: the Git tag is the version, and there is no string to bump or forget. Reach for uv version when you want a committed version field and a manual release step; reach for uv-dynamic-versioning when you want the tag to drive the version with no edits.
One trade-off comes with the switch: once version is dynamic, uv version stops working and reports We cannot get or set dynamic project versions. Reading dynamic versions through uv version is tracked in astral-sh/uv#14137.
How is uv-dynamic-versioning different from setuptools-scm or hatch-vcs?
All three read the version from Git. setuptools-scm targets setuptools, hatch-vcs targets hatchling, and uv-dynamic-versioning is a lighter hatchling plugin with defaults tuned for uv projects. For a new uv project, uv-dynamic-versioning is the shortest path. Existing hatch-vcs setups work fine as-is.
Why did my CI build publish 0.0.0?
The runner did a shallow clone, so the tags never arrived. A missing tag is not an error: the build exits 0 and produces 0.0.0.post1.dev0+<commit>, so the problem shows up as a wrong version on PyPI rather than a red build. Set fetch-depth: 0 on actions/checkout. Reserve fallback-version for builds that have no Git repository at all.
Why does uv add fail on a dynamic-versioning project?
It doesn’t, as long as only version is dynamic. uv add builds the project to resolve it, and that build reads the tag like any other. The failure appears when dependencies is also marked dynamic (dynamic = ["version", "dependencies"]): uv add writes the new package into [project.dependencies], which cannot coexist with a dynamic dependencies field, so the build aborts:
ValueError: Metadata field `dependencies` cannot be both statically
defined and listed in field `project.dynamic`
Projects that also configure [tool.hatch.metadata.hooks.uv-dynamic-versioning] see the same failure worded differently: ValueError: 'dependencies' is dynamic but already listed in [project]. Keep dependencies static and let uv manage it; only version needs to be dynamic. Reconciling the two is tracked in astral-sh/uv#12837.
Learn More
- uv: A Complete Guide covers what uv does, how fast it is, the core workflows, and recent releases.
- uv-dynamic-versioning on GitHub
- pyproject.toml reference covers project metadata fields including
dynamic - What is a build backend? explains how hatchling and other backends work
- Why does uv use hatch as a backend? covers the default build backend for uv projects
- Setting up GitHub Actions with uv covers the CI basics the release workflow builds on
- uv reference documents the
uv buildanduv publishcommands
This handbook is free, independent, and ad-free. If it saved you time, consider sponsoring it on GitHub.