# The dist directory is the product, and the cache key is a hash

> actions/setup-python is the step almost every Python workflow depends on, and its documentation is more instructive than its feature list. It resolves a version from a hosted tool cache before it downloads anything, it will silently use whatever Python is on the runner if you do not pin a version, its dependency cache is keyed on a hash of your requirements file and is skipped when that file goes stale, and the JavaScript your runner executes is a committed bundle rather than the TypeScript in src.

**actions/setup-python** — Set up your GitHub Actions workflow with a specific version of Python

- Repository: https://github.com/actions/setup-python
- Stars: 2,226 · Forks: 738
- Language: TypeScript
- License: MIT
- Published: 2026-09-29 · Updated: 2026-09-29 · Language: en
- Canonical page: https://hysenlabs.com/projects/actions-setup-python

## Three jobs, and one of them most workflows depend on

The README describes the action in three bullets and they define the whole surface. It installs a version of Python or PyPy and, by default, adds it to the path. It optionally caches dependencies for pip, pipenv and poetry. And it registers problem matchers for error output, which is the feature nobody thinks about and every Python user notices when it is missing, because a traceback in a CI log becomes a wall of text instead of a file and line. The basic usage is three steps, and the third is your own script:

```yaml
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
  with:
    python-version: '3.13'
- run: python my_script.py
```

Note the shape of that snippet, because it is the shape of most GitHub Actions workflows in the Python ecosystem. The action is referenced by a major version tag, the checkout immediately before it is also a major tag, and the interpreter version is quoted as a string so YAML does not turn a float out of it. The same three steps work for PyPy, for GraalPy and for a free-threaded build, with only the version string changing.

## If you do not pin a version, the runner decides

This is the paragraph most people skip and the one that explains a class of mysterious CI failure. The python-version input is optional. If you omit it, the action tries to resolve the version from a default .python-version file in the repository. If that file does not exist, the version already on the path is used, and the README says the default Python or PyPy on the path varies between runners and can be changed unexpectedly, which is why it recommends always setting the version explicitly with either the python-version or the python-version-file input. The failure mode is specific and unpleasant. A workflow that never pinned a version passes today, passes for a year, then fails because the image was rebuilt with a newer interpreter, or fails in the other direction because a pinned dependency stopped supporting the interpreter the image happened to ship. The recommendation costs one line and removes the whole class of problem, and the python-version-file input exists so that the version can live in a file that more than one workflow reads, which is the right shape for a repository that has many jobs.

## Tool cache first, downloads second, from two different places

The resolution order is stated precisely and it explains the speed of the action. The action first checks the local tool cache for a semver match, which is the directory of interpreters the hosted runner image already contains. If it cannot find the specific version there, it attempts to download a version of Python from the GitHub Releases page of the python-versions repository, and for PyPy from the official PyPy distribution site. So there are two different sources depending on which interpreter you asked for, and one of them is a mirror GitHub maintains while the other is upstream. For a hosted runner, the common case is the first path, where the version is already on disk and the action is a matter of pointing the path at it. If you are on a self-hosted runner, the download path is the one you will see, and the list of what a hosted image already contains is documented separately in the runner images repository. The architecture input sits alongside this, taking x86, x64 or arm64 and defaulting to the host operating system's architecture, which is the setting you need on an ARM runner where the default and the interpreter you want are not the same thing.

## The version input is a selector language, not a version

The python-version input is documented as supporting the Semantic Versioning Specification plus a set of special notations, with semver ranges and an x.y-dev syntax among the examples, and the advanced usage guide holds the full set. That framing is the right one, because the same input resolves four different families. A plain version such as 3.13 is CPython. A suffix of t selects the free-threaded build, as in 3.13t, which is the input that matters if you are testing the no-GIL interpreter. A language prefix selects an alternative implementation, with pypy3.10 and graalpy-24.0 as the documented examples. And a range or a dev notation selects something looser. All four are typed into the same string field, so the practical risk is a value that parses as a range when you meant an exact version, which is why quoting the value and reading the resolved version from the step's output rather than assuming is worth doing. The action also exposes outputs and environment variables, documented in the same guide, which is what you use to confirm what you actually got.

## Caching is off by default and keyed on a file hash

The cache input is optional and disabled by default, and when you turn it on the action searches the repository for a dependency file and uses that file's hash as part of the cache key. The defaults are per package manager and they are not interchangeable. For pip it looks for requirements.txt or pyproject.toml. For pipenv it looks for Pipfile.lock. For poetry it looks for poetry.lock. What gets cached also differs: pip caches the global cache directory, pipenv caches the virtualenv directory, and poetry caches virtualenv directories, one for each poetry project found. If your dependency files live in subdirectories, or you have more than one and want a particular one in the key, that is what cache-dependency-path is for. Under the hood the action uses the toolkit cache package, and the README's claim is that it needs less configuration than calling that package yourself, which is true for the common case and worth testing for a monorepo where the defaults will pick a file you did not mean.

## Two ways the cache silently does nothing

The README explains two situations where a restored cache is not used, and both are common. The first is time. If the requirements file has not been updated for a long time and a newer version of a dependency is available, the restored cache is not used, which can increase total build time. The reasoning is that a cache that is behind the index is worse than no cache for a job whose purpose is testing against current packages. The second is pinning. If your requirements file allows logical operators such as a minimum version, or specifies dependencies with no version at all, then pip install always tries the newest available package, and the README says to stick to a specific dependency version and update it manually if necessary in order for the cache to be used. A third caveat is about scope rather than timing: the action does not handle authentication for pip when installing from private repositories, and the README points you at pip's own documentation for that. If you install from a private index, the cache and the credentials are your problem, not the action's.

## Your runner executes dist/, not src/

The repository layout explains something about JavaScript actions that surprises people. The source is TypeScript in src, there is a committed dist directory, and the package is marked private with its main entry pointing into dist. The build script compiles two entry points separately, one for setup and one for the post-job cache save, using a bundler, and the release script runs the same two builds and then force-adds the dist directory to the index. The reason is the execution model. GitHub Actions runs a JavaScript action by checking out the tag you referenced and running the committed bundle, so dist is the artefact your workflow actually consumes, which makes it the thing to review if you have a policy about supply chain. It also means a pull request that changes src without regenerating dist is not what a reviewer would assume. The type field is module, the engine requirement is node 24 or newer, and the v7 notes say the internals were migrated to ESM for compatibility with the current @actions packages with no change to inputs, outputs or behaviour, so v6 to v7 is an internal change and v5 to v6 is the one with a floor, the runner version 2.327.1.

## Against a container job, and against whatever the image already has

There are two alternatives and they make different trade-offs. A container job pins the interpreter by pinning the image, which is the strongest possible statement of what runs, and it costs a pull of a large image on a cold cache plus the loss of GitHub's tool cache for anything you also need. Skipping the action entirely and using the interpreter already on the runner costs nothing and is the reason the README warns about it, since the version is a property of the image and moves under you. A third option is a matrix of versions without this action at all, which is how you test a library against several interpreters, and it works because the action makes multiple versions cheap rather than because it is the only way to have more than one. What the action genuinely buys is the combination, a pinned interpreter, a warm dependency cache, readable tracebacks, and a single line of YAML that every Python maintainer already recognises. The licence is MIT, the version is 7.0.0, and the repository carries the usual GitHub Actions furniture including a dependency licence checker configuration and a Jest test suite run with the experimental VM modules flag.

## Conclusion

Adopt setup-python at v7 for any Python job that needs a specific interpreter, since the resolution chain and the built-in cache save you a container build. Do not adopt it if your workflow already runs in a container image you control, where the interpreter is part of the image and there is nothing to resolve. Verify four things before you bump a workflow to v7: that your runner is v2.327.1 or newer, the floor the v6 release notes set when the action moved from node20 to node24, that you pass python-version or python-version-file explicitly rather than relying on the runner's default, that your requirements or lock file pins exact versions so the cache key stops moving. The action is MIT, version 7.0.0 shipped on 2026-07-20, and the last push to main was 2026-09-28.

## FAQ

### Do I have to set python-version in actions/setup-python?

No, but the README strongly recommends it. Without it the action reads a .python-version file, and without that file it uses whatever Python or PyPy is already on the path, and the README notes that default varies between runners and can change unexpectedly.

### Which interpreters can actions/setup-python install?

Python, PyPy and GraalPy, plus a free-threaded Python build selected with a t suffix such as 3.13t. The version input supports semantic versioning plus special notations including semver ranges and an x.y-dev syntax, and the architecture input accepts x86, x64 or arm64.

### How does caching dependencies work in actions/setup-python?

The cache input is optional and off by default. When enabled the action finds a dependency file, requirements.txt or pyproject.toml for pip, Pipfile.lock for pipenv, or poetry.lock for poetry, and uses its hash in the cache key. Use cache-dependency-path when the file lives elsewhere or you have several.

### Why is my restored cache not being used?

Two documented reasons. If the requirements file has not been updated for a long time and a newer dependency version is available, the cache is skipped. And if the requirements allow logical operators or no versions at all, pip always installs the latest package, so the README recommends pinning specific versions and updating them manually.

### What broke when I moved to setup-python v7?

V7 migrated the action internals to ESM with no change to inputs, outputs or behaviour. The breaking change was in v6, which moved from node20 to node24, and the release notes require a runner at v2.327.1 or later for compatibility with that release.

### Where does setup-python download interpreters from?

It first looks in the hosted runner's local tool cache for a semver match. Failing that it downloads Python from the GitHub Releases page of the actions/python-versions repository, and PyPy from the official PyPy distribution site.

## Sources

- [actions/setup-python on GitHub](https://github.com/actions/setup-python)
- [Issues](https://github.com/actions/setup-python/issues)
- [License: MIT](https://github.com/actions/setup-python/blob/main/LICENSE)
- [README](https://github.com/actions/setup-python/blob/main/README.md)
- [Releases](https://github.com/actions/setup-python/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/actions-setup-python
