# 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](https://pydevtools.com/handbook/reference/pyproject.toml.md). The release workflow becomes a single `git tag` command: the build backend reads the tag, and the [wheel](https://pydevtools.com/handbook/reference/wheel.md) and source distribution filenames pick up the matching version automatically.

This guide uses [uv-dynamic-versioning](https://github.com/ninoseki/uv-dynamic-versioning/), a [hatchling](https://pydevtools.com/handbook/reference/hatch.md) plugin that ships a sensible default configuration for [uv](https://pydevtools.com/handbook/reference/uv.md) 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](https://github.com/astral-sh/uv/issues/14561).

## Prerequisites

- A Git repository for your Python project with at least one commit
- [uv](https://pydevtools.com/handbook/reference/uv.md) installed on your system
- A `src/` layout holding the package directory (`uv init --package` creates one)

## Configure the build system

Update `pyproject.toml` to use uv-dynamic-versioning as a [build backend](https://pydevtools.com/handbook/explanation/what-is-a-build-backend.md) dependency:

```toml
[build-system]
requires = ["hatchling", "uv-dynamic-versioning"]
build-backend = "hatchling.build"
```

> [!NOTE]
> This sets [hatchling](https://pydevtools.com/handbook/reference/hatch.md) as the [build backend](https://pydevtools.com/handbook/explanation/what-is-a-build-backend.md), 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 [how uv_build and Hatchling compare](https://pydevtools.com/handbook/explanation/what-is-the-uv-build-backend.md).

## Set the version source

Mark the version field as dynamic and point hatchling at uv-dynamic-versioning:

```toml
[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:

```console
$ 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):

```console
$ git tag v0.1.0
```

## Build and verify

```console
$ 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](https://pydevtools.com/handbook/explanation/versioning-python-packages-semver-calver-and-pep-440.md) plus the commit hash as a local identifier:

```
your_project-0.1.0.post1.dev0+91cc190-py3-none-any.whl
```

The `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](https://github.com/mtkennerly/dunamai), the engine shared by several VCS-versioning tools. Configure its output under `[tool.uv-dynamic-versioning]`:

```toml
[tool.uv-dynamic-versioning]
style = "pep440"        # also "semver" or "pvp"
pattern = "default"     # "default" requires a "v" prefix; "default-unprefixed" drops it
```

- `style` selects the version scheme: `pep440` (the default), `semver`, or `pvp`. The `pep440` default produces `0.1.0.post1.dev0+91cc190`. Wheel and sdist filenames are normalized to PEP 440 regardless, so keep `pep440` unless another tool reads the raw version string.
- `pattern` decides which tags count as releases. `default` matches `v1.2.3`; `default-unprefixed` matches `1.2.3`.
- The commit hash carries no prefix by default. Set `commit-prefix = "g"` for the `git describe`-style `+g91cc190`.

## Expose the version at runtime

To make the version accessible within the package:

```python
# 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 tree
```

This 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`:

```yaml
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@v10.1.0
        with:
          enable-cache: false  # Keep release builds independent of PR caches
      - run: uv build
      - run: uv publish
```

`fetch-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](https://pydevtools.com/handbook/how-to/how-to-publish-to-pypi-with-trusted-publishing.md) 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:

```console
$ 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:

```console
$ 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:

```toml
[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](https://github.com/astral-sh/uv/issues/14137).

### How is uv-dynamic-versioning different from setuptools-scm or hatch-vcs?

All three read the version from Git. [setuptools-scm](https://setuptools-scm.readthedocs.io/) targets setuptools, [hatch-vcs](https://github.com/ofek/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:

```console
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](https://github.com/astral-sh/uv/issues/12837).
