setuptools-scm
setuptools-scm works out your package version from version control instead of a string you maintain by hand. Build from a commit tagged v1.4.0 and the wheel says 1.4.0. Build from three commits later and you get a PEP 440 development version like 1.4.1.dev3+g4a2b1c9, with the commit distance and short hash carried in the version itself. No file in your source tree holds the number, so there is no bump commit, nothing to forget, and no way for the git tag and the published metadata to disagree. It hooks into setuptools as a build plugin: you list it in build-system.requires, mark version as dynamic in [project], and it takes over at build time. It also registers a setuptools file finder, which quietly adds every SCM-tracked file to your source distribution whether or not you use it for versioning. Since version 10 the actual inference lives in a separate package called vcs-versioning, which means git, Mercurial, and Jujutsu backends are shared with other tools such as hatch-vcs.
If you release from tags on a setuptools project, this removes a whole class of mistakes and is worth the setup. Budget an afternoon for the CI side, because shallow clones, Docker builds without .git, and PyPI's rejection of local version segments will each bite you once.
Use it if
- You cut releases from git tags and want the tag to be the only source of truth, so a CI job cannot ship a wheel whose metadata disagrees with the tag it was built from
- You publish development builds to an internal index and need every commit to produce a unique, correctly sortable PEP 440 version rather than overwriting 1.4.0.dev0 forever
- You want your sdist to contain everything git tracks without hand-maintaining MANIFEST.in, which is the second half of what this package does
- You are on the setuptools backend and do not want to migrate the whole project to Hatch or PDM just to get version inference
- You work in a monorepo with per-package tags: tag.prefix filters git describe to that package's namespace and strips the prefix before parsing
- Your builds do not carry git history. GitHub Actions checks out with fetch-depth 1 by default, Docker builds normally exclude .git through .dockerignore, and Bazel and Nix sandboxes hand you a bare source tree. In every case you get a meaningless 0.1.dev1 version or an outright failure, and the fix is fallback_version or SETUPTOOLS_SCM_PRETEND_VERSION, which puts the hard-coded string right back where you were trying to remove it
- You already build with Hatch, PDM, Flit, or Poetry. hatch-vcs wraps the same inference for hatchling and pdm-backend has its own SCM support; bolting setuptools-scm on means dragging setuptools into a build that had escaped it
- You need version numbers a human can repeat over the phone. 1.4.1.dev3+g4a2b1c9.d20260803 is valid PEP 440 and awkward everywhere else: it lands in Docker tags, log lines, and support tickets. Worse, PyPI rejects any version with a local segment (the part after +), so uploading non-tag builds requires setting local_scheme to no-local-version and remembering why
- You are surprised by side effects. The README puts a warning box on this: installing setuptools-scm always activates a file finder that sweeps every SCM-tracked file into your sdist, and the docs state plainly that this cannot be turned off through configuration. If you track fixtures, screenshots, or a large sample corpus, they ship until you write MANIFEST.in prune rules
- You depend on the version file appearing in your working tree. Version 10 moved version_file writing from the source tree into the build directory during build_py, so a script that read src/pkg/_version.py after an inference step now finds nothing unless you set SETUPTOOLS_SCM_WRITE_TO_SOURCE=1
- You want a settled configuration surface. 9.2.0 removed the old implicit activation, 10.0 split the engine out into vcs-versioning, tag_regex was deprecated in favour of a tag table, and leaving tag.strict unset currently prints a FutureWarning because the default is going to change. None of these are large, but you will be reading changelogs at upgrade time
Setup reality
Put setuptools-scm in build-system.requires, never in your runtime dependencies. Then set dynamic = ["version"] in [project], delete any static version = line, and add a [tool.setuptools_scm] table, which may be empty. Leaving the static version in place produces a conflict warning; omitting dynamic means nothing happens at all. Note that the table name uses an underscore while the distribution name uses a hyphen. Since 9.2.0 the old trick of relying on the dependency alone no longer activates inference: you either write the section or ask for the setuptools-scm[simple] extra. It needs setuptools 61 as a hard minimum with 80 or newer recommended, and it pulls vcs-versioning, packaging, and setuptools itself, plus tomli and typing-extensions on Python older than 3.11. CI is where the actual work is. actions/checkout defaults to a shallow clone with no tags, so every workflow needs fetch-depth: 0 or an explicit git fetch --tags --force, and this is far and away the most common way people get a wrong version. Multi-stage Dockerfiles that copy the source without .git behave identically. Any build that is not exactly on a tag gets a local version segment, which PyPI refuses, so release workflows need local_scheme = "no-local-version" or an override. Generated version files must be gitignored. And expect a FutureWarning about tag.strict until you set it explicitly, since the permissive default that matches any tag containing a digit is scheduled to become strict.
Patterns
Turn on version inferenceminimal-pyproject-setup
[build-system]
requires = ["setuptools>=80", "setuptools-scm>=10"]
build-backend = "setuptools.build_meta"
[project]
name = "mypkg"
dynamic = ["version"] # and NO static version = line
[tool.setuptools_scm] # may be empty, but must be present
# alternative with no section at all:
# requires = ["setuptools>=80", "setuptools-scm[simple]>=10"]Three things have to line up: the build requirement, dynamic = ["version"], and either the tool table or the [simple] extra. Miss the table and 9.2.0 or later will not infer anything, because implicit activation was removed after projects that only wanted the file finder started getting versions rewritten. The table is spelled tool.setuptools_scm with an underscore even though the package name has a hyphen.
Generate _version.py at build timewrite-a-version-file
[tool.setuptools_scm]
version_file = "src/mypkg/_version.py"
version_file_template = '''
__version__ = version = {version!r}
__version_tuple__ = version_tuple = {version_tuple!r}
__commit__ = {scm_version.node!r}
'''
# .gitignore
# src/mypkg/_version.pyNever commit the generated file; a stale one shadowing the real version is a classic afternoon lost. As of version 10 it is written into the build directory during build_py rather than into your checkout, and src/ layouts are remapped automatically from the package_dir config. If a local workflow genuinely needs it in the tree, set SETUPTOOLS_SCM_WRITE_TO_SOURCE=1 rather than reverting to an older release.
Expose __version__ without a generated fileread-version-at-runtime
# src/mypkg/__init__.py
from importlib.metadata import PackageNotFoundError, version
try:
__version__ = version("mypkg")
except PackageNotFoundError:
# running from a source checkout that was never installed
__version__ = "0.0.0+unknown"This reads the installed distribution metadata, so it works whether or not a version file was generated and keeps import time down. The argument is the distribution name from [project].name, not the importable module name, and those differ more often than you would expect. Pick either this or version_file; maintaining both gives you two numbers that can drift.
Ask what version a build would produceinspect-the-computed-version
pip install setuptools-scm
python -m setuptools_scm # print the version it infers here
python -m setuptools_scm ls # list files the file finder will ship
python -m setuptools_scm --help
SETUPTOOLS_SCM_DEBUG=1 python -m setuptools_scmRun this first whenever CI produces a version you did not expect; it answers the question in one second instead of after a full build and upload. SETUPTOOLS_SCM_DEBUG prints the backend discovery and the git commands it ran, which is how you find out that git describe matched a tag from a different package in the monorepo.
Control the shape of the version stringchoose-version-and-local-scheme
[tool.setuptools_scm]
version_scheme = "guess-next-dev" # default
# also: post-release, no-guess-dev, python-simplified-semver, release-branch-semver
local_scheme = "node-and-date" # default
# also: node-and-timestamp, dirty-tag, no-local-versionThe local segment (everything after the +) is what makes dev builds traceable and also what PyPI refuses to accept, so release pipelines set local_scheme = "no-local-version". Setting it permanently in pyproject.toml means local builds lose the commit hash too, so most projects override it per job with SETUPTOOLS_SCM_OVERRIDES_FOR_MYPKG instead. SOURCE_DATE_EPOCH is honored for the date-based schemes if you need reproducible output.
Stop CI from producing 0.1.dev1fetch-full-history-in-ci
- uses: actions/checkout@v6
with:
fetch-depth: 0 # tags and full history
# if something upstream already did a shallow clone
- run: git fetch --prune --unshallow --tags --forceThis single line is the most common bug report the project gets. A shallow checkout has no tags, so git describe finds nothing, and the build silently produces a version derived from the root commit rather than failing. Tags are also not fetched by some mirrors and caches even at full depth, hence the explicit --tags --force fallback.
Pretend versions and fallbacks for history-free buildsoverride-the-version
# preferred: scoped to one distribution
SETUPTOOLS_SCM_PRETEND_VERSION_FOR_MYPKG=1.4.0 python -m build
# unscoped, applies to every package built in this process
SETUPTOOLS_SCM_PRETEND_VERSION=1.4.0 python -m build
# last resort, in pyproject.toml
[tool.setuptools_scm]
fallback_version = "0.0.0"Always prefer the _FOR_${DIST_NAME} form: the name is upper-cased with runs of dot, dash, or underscore collapsed to a single underscore, and scoping it stops one variable from rewriting the version of every dependency built from source in the same pip run. fallback_version converts a loud failure into a quietly wrong version, which is right for distro packagers and wrong for your own release job.
Keep tracked-but-unwanted files out of the tarballtrim-the-sdist
# MANIFEST.in
prune docs/_build
prune testing/fixtures/corpus
exclude .pre-commit-config.yaml
exclude tox.ini
global-exclude *.pyc __pycache__/
# verify
python -m setuptools_scm ls
python -m build --sdist && tar -tzf dist/mypkg-*.tar.gzThe file finder is active from the moment setuptools-scm is installed in the build environment, regardless of whether you use it for versioning, and the docs say directly that it cannot be disabled through configuration. MANIFEST.in prune and exclude rules are the only lever. Check the listing before your first release rather than after someone downloads a tarball full of test data.
Make GitHub release tarballs versionablesupport-git-archive-tarballs
# .git_archival.txt (commit this file)
node: $Format:%H$
node-date: $Format:%cI$
describe-name: $Format:%(describe:tags=true,match=*[0-9]*)$
# .gitattributes (commit this too)
.git_archival.txt export-substThe auto-generated source tarballs on a GitHub release page are git archives with no repository attached, so a build from one has no version unless these placeholders were substituted at archive time. Both files must be committed before the tag is created; adding them afterwards does not fix already-published tarballs. A vcs-versioning 2.2.3 fix covers tags containing more than one dash, which used to be parsed wrongly here.
Per-package tags in a shared repositoryconfigure-a-monorepo
# packages/mypkg/pyproject.toml
[tool.setuptools_scm]
root = "../.."
relative_to = "pyproject.toml"
[tool.setuptools_scm.tag]
prefix = "mypkg-v" # matches mypkg-v1.4.0, strips to 1.4.0
strict = true # tags must contain a dotroot is resolved relative to the file named in relative_to, so point that at the package's own pyproject.toml. Without tag.prefix every package in the repo describes against whichever tag was created most recently and they all report the same version. Prefer tag.prefix and tag.strict over the older top-level tag_regex, which now raises a DeprecationWarning and cannot be combined with the new tag.regex.
Get a real version without shipping .gitbuild-inside-docker
# option A: compute on the host, pass it in
# docker build --build-arg VERSION="$(python -m setuptools_scm)" .
ARG VERSION
ENV SETUPTOOLS_SCM_PRETEND_VERSION_FOR_MYPKG=${VERSION}
COPY . /src
RUN pip install /src
# option B: build the wheel in a stage that does have history
# COPY .git /src/.gitAlmost every .dockerignore excludes .git, which is correct for image size and is exactly why the container reports 0.1.dev1. Option A keeps history out of every layer and is the usual answer. If you take option B, do the wheel build in an earlier stage and copy only the wheel forward, otherwise the repository ends up in your shipped image.
Check the artifact metadata before you push a tagverify-before-tagging
python -m build
unzip -p dist/mypkg-*.whl 'mypkg-*.dist-info/METADATA' | grep '^Version:'
tar -tzf dist/mypkg-*.tar.gz | head -20
git tag -a v1.4.0 -m "1.4.0"
git push origin v1.4.0Reading the version straight out of the built METADATA is the only check that cannot lie to you, since it is exactly what PyPI will index. Note that 10.2.1 stopped writing scm_version.json and scm_file_list.json into a wheel's .dist-info while keeping them in sdists for fallback discovery, so the two artifact types legitimately differ if you go looking.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| hatch-vcs | PyPI | Your build backend is hatchling; it is the same inference wired into Hatch instead of setuptools. |
| versioningit | PyPI | You need heavier customization of how the version is formatted or written, configured entirely in pyproject.toml without dropping to setup.py callables. |
| dunamai | PyPI | You want the git-to-version logic as a plain library or CLI you call yourself, decoupled from any build backend. |
| setuptools-git-versioning | PyPI | You want a smaller setuptools plugin with template-based version strings and no separate engine package underneath. |