uv on macOS: Managing Python Versions, Projects, and Tools

This guide is for Python developers on macOS who are moving to uv from pip, venv, pip-tools, pyenv, or pipx. uv supports four kinds of work: a project, a single-file script, a one-off CLI run with uvx, and a CLI installed on PATH. All four can use the Python versions that uv installs. You will learn the commands for each and how to choose between them.

I switched my own Python workflow to uv. One executable now covers the work I used to split across those tools.

Quick start

# Install uv with Homebrew
brew install uv

# Start a project
uv init --no-package example-app
cd example-app
uv add httpx
uv run python main.py
# Prints "Hello from example-app!"

# Run an isolated CLI without installing it permanently
uvx ruff check .

The figure shows where each of the four workflows keeps its environment, what you keep for each, and the managed Python installs they share.

Four uv workflows crossed by storage bands: the project .venv next to pyproject.toml and uv.lock, script and uvx environments in the disposable uv cache, an installed tool environment that uvx can reuse in the tool directory, and managed Python installs under all four. Installed commands need the executable directory on PATHFour uv workflows crossed by storage bands: the project .venv next to pyproject.toml and uv.lock, script and uvx environments in the disposable uv cache, an installed tool environment that uvx can reuse in the tool directory, and managed Python installs under all four. Installed commands need the executable directory on PATH

Install and update uv

Homebrew is a convenient macOS installation path. See uv’s installation guide for the supported methods:

brew install uv
uv --version

If Homebrew installed uv, Homebrew should upgrade it:

brew upgrade uv

uv self update is for uv’s standalone installation method and is disabled for package-manager installations. Upgrade uv with the same tool that installed it.

To see where uv, its Python installs, its tools, and its cache live:

command -v uv
uv python dir
uv tool dir
uv cache dir

Managed Python installations, persistent tools, and disposable cache entries have separate directories. The cache may be deleted and rebuilt. It is not a source of truth. On macOS, uv clones packages from the cache into each environment (copy-on-write) instead of copying them. Rebuilding .venv reuses any needed artifacts still in the cache.

Projects: declarations, resolution, and environment

A uv project normally has three parts:

  • pyproject.toml declares project metadata and direct requirements.
  • uv.lock stores uv’s cross-platform resolution.
  • .venv is the local installed environment and should be disposable.

The figure sorts the project files by who writes them and whether git keeps them.

You edit pyproject.toml, .python-version and the source; uv lock writes uv.lock; all four are committed. uv sync builds .venv from uv.lock, with packages cloned from the uv cache, and both stay out of gitYou edit pyproject.toml, .python-version and the source; uv lock writes uv.lock; all four are committed. uv sync builds .venv from uv.lock, with packages cloned from the uv cache, and both stay out of git

Create an application and add runtime and test requirements:

uv init --no-package forecast-app
cd forecast-app

uv add httpx
uv add --group test pytest
uv run python main.py
uv run --group test pytest --version

The application prints Hello from forecast-app!; the final command checks that pytest is installed. Once you have tests, run them with uv run --group test pytest.

The --no-package template creates main.py, pyproject.toml, README.md, and .python-version without a build system. Since uv 0.12, plain uv init creates a packaged application with a src layout and build backend. Use uv init --lib for a packaged library. See uv’s project creation guide.

uv run checks the project, updates the lock when needed, syncs required dependencies, and runs the command. See uv’s project layout and sync documentation for this lifecycle. The first project operation creates .venv and uv.lock as needed. uv init itself only creates the project files.

uv installs the dev group by default. Other groups, such as test, need --group on each command, or default-groups under [tool.uv]. To install one group alone, for example in a docs CI job, use uv sync --only-group docs.

Groups stay local and are never published. A published extra is different: uv add --optional cli rich adds an extra that users install as forecast-app[cli].

To use the project in Jupyter notebooks in VS Code, run uv add --dev ipykernel and select .venv as the kernel. See uv’s Jupyter guide.

Commit inputs, not the environment

Commit pyproject.toml, uv.lock, source, and an intentional .python-version. Ignore .venv and uv’s caches.

A teammate who clones the repository gets the locked environment with one command:

git clone https://github.com/example/forecast-app.git
cd forecast-app
uv sync --locked

To see why a package is installed, and to move the lock to newer versions:

uv tree                            # show the dependency graph
uv tree --outdated                 # show newer versions where they exist
uv lock --upgrade-package httpx    # move one package, keep the rest
uv lock --upgrade                  # re-resolve everything

Commit the changed uv.lock with the change that needed it.

The lock records a resolution across supported markers and platforms. It does not make native wheels identical across macOS, Linux, Intel, and Apple Silicon. Test every deployment platform.

uv can enforce part of this. required-environments under [tool.uv] makes resolution fail when a package without a source distribution has no wheel for a listed platform:

[tool.uv]
required-environments = [
    "sys_platform == 'darwin' and platform_machine == 'arm64'",
    "sys_platform == 'linux' and platform_machine == 'x86_64'",
]

no-build-package forbids source builds for named packages, so a missing wheel fails the install instead of starting a compiler. If you also deploy PyTorch on Linux with CUDA, follow uv’s PyTorch guide for the index setup.

Lock freshness: --locked is not --frozen

By default, project commands may update uv.lock when declarations change. See uv’s locking and syncing documentation for the freshness and exactness flags.

Use --locked to require the lock to be current with project metadata:

uv lock --check
uv sync --locked --group test
uv run --locked --group test pytest

If pyproject.toml and uv.lock disagree, these commands fail instead of resolving a new lock. Use them in CI so a stale lock fails the build.

Use --frozen only when you intentionally want uv to use the existing lock without checking whether it is current:

uv sync --frozen

That can be useful in a controlled build stage where the lock was already validated, but it is not a stale-lock detector.

The figure follows one stale project through four commands: rich was added to pyproject.toml by hand, and uv.lock was not updated.

With rich in pyproject.toml but not in uv.lock: uv sync rewrites the lock and installs rich, uv sync --locked and uv lock --check fail, and uv sync --frozen installs httpx without rich while CI stays greenWith rich in pyproject.toml but not in uv.lock: uv sync rewrites the lock and installs rich, uv sync --locked and uv lock --check fail, and uv sync --frozen installs httpx without rich while CI stays green

Exactness is a separate setting from freshness. uv sync is exact by default and removes extraneous packages. uv run uses an inexact sync by default unless --exact is requested. The figure follows one package installed by hand through uv sync, uv run, and uv run --exact.

A .venv with httpx, ipykernel, and a hand-installed extra-pkg: uv sync and uv run --exact remove extra-pkg, uv run keeps itA .venv with httpx, ipykernel, and a hand-installed extra-pkg: uv sync and uv run --exact remove extra-pkg, uv run keeps it

Install and pin Python versions

uv can download and manage Python distributions:

uv python install 3.12 3.13
uv python list --only-installed
uv python pin 3.12

uv’s managed CPython builds come from the python-build-standalone project. uv can also discover system, Homebrew, pyenv, Conda, and other interpreters. See uv’s Python version documentation for the supported sources and discovery behavior.

.python-version is a version request discovered by uv and other compatible tools. uv python pin writes this file, and uv init creates one. requires-python in pyproject.toml is the project’s compatibility contract. Keep both intentional:

[project]
requires-python = ">=3.12,<3.14"

uv takes the version request from --python first, then .python-version, then requires-python.

Pinning 3.12 does not guarantee the same patch build forever or the same artifact on every operating system. Request an exact patch when you need one. In CI, select the interpreter and print it, with uv run --python 3.12.7 python -VV or by setting UV_PYTHON=3.12.7 for the job.

Use another Python manager when a project needs a distribution or build configuration uv does not supply. uv can still use that interpreter via --python or normal discovery.

Choose among uv run, uvx, and installed tools

Choose by whether the tool needs the project’s installed packages, must stay on PATH, or can run independently. The figure shows which dependencies are available and how long each environment lives.

uv run uses the project .venv and its locked dependencies; uvx uses a separate cached or installed tool environment without automatically installing project dependencies; uv tool install keeps an environment in the tool directory and makes its command available after PATH setupuv run uses the project .venv and its locked dependencies; uvx uses a separate cached or installed tool environment without automatically installing project dependencies; uv tool install keeps an environment in the tool directory and makes its command available after PATH setup

Project-coupled tools: uv run

If pytest, mypy, a code generator, or another tool must import the project or use its locked plugins, declare it in a dependency group:

uv add --group lint ruff
uv run --group lint ruff check .

uvx does not automatically install the project’s package or dependencies. A tool can still import local source files if its import behavior allows it; use uv run when it needs the installed project or its dependencies.

Ad hoc tools: uvx

uvx is an alias for uv tool run. It reuses a compatible tool installed with uv tool install; otherwise, it uses an isolated environment stored in the disposable uv cache. The uv tools documentation describes this cache and the persistent-tool lifecycle:

uvx ruff@0.12.0 check .
uvx ruff@latest check .
uvx --from jupyterlab jupyter lab
uvx --from httpie http --version
uvx --with mkdocs-material mkdocs --help

Pin the tool version in CI or documentation. With no installed tool, an unversioned first invocation selects the latest available release; later invocations may reuse cache state. @latest ignores an installed version and refreshes the cache for the newest release. Use --from when the package name differs from the command name, as with jupyterlab and httpie.

Persistent executables: uv tool install

Install a tool when scripts outside your control need its command on PATH, or when your machine setup manifest should own it:

uv tool install ruff
uv tool list
uv tool upgrade ruff
uv tool upgrade --all
uv tool uninstall ruff

If installation warns that the executable directory is absent from PATH, run uv tool update-shell and restart your shell before using the command. See uv’s tool executable documentation.

Persistent tools still use isolated environments. Do not mutate those environments manually with pip.

Single-file scripts with PEP 723

PEP 723 defines inline script metadata. A compatible runner can read the comment block and build an isolated environment. See the uv scripts guide and PEP 723 for the metadata format and runner behavior.

The figure marks the metadata block in fetch.py and shows what uv run and plain python do with the file.

fetch.py with its PEP 723 comment block highlighted: uv run reads the block, finds Python 3.12 or newer, installs httpx into an isolated environment, and runs the code; python fetch.py runs only the codefetch.py with its PEP 723 comment block highlighted: uv run reads the block, finds Python 3.12 or newer, installs httpx into an isolated environment, and runs the code; python fetch.py runs only the code

uv can write the metadata block for you:

uv init --script fetch.py --python 3.12

A finished script looks like this:

# /// script
# requires-python = ">=3.12"
# dependencies = [
#   "httpx>=0.27,<1",
# ]
# ///
import httpx

response = httpx.get("https://example.com", timeout=10)
response.raise_for_status()
print(response.status_code)

Run and edit metadata with uv:

uv run fetch.py
uv add --script fetch.py rich
uv remove --script fetch.py rich

Plain python fetch.py ignores the comment metadata. The script therefore depends on a compatible runner even though the Python syntax remains valid. uv is one runner. pipx run, Hatch, and PDM also read the block.

To run the script as ./fetch.py, add a shebang that calls uv:

#!/usr/bin/env -S uv run --script
# /// script
# dependencies = ["httpx"]
# ///
import httpx
chmod +x fetch.py
./fetch.py

The -S flag makes env split the line into separate arguments, so --script reaches uv.

For a quick experiment, pass dependencies on the command line instead of writing metadata. The second line starts a Python REPL with pandas available:

uv run --with httpx scratch.py
uv run --with pandas python

For a script that must reproduce a resolution later, create an adjacent lock:

uv lock --script fetch.py
uv run --script fetch.py

This writes fetch.py.lock. An exclude-newer timestamp in the metadata block makes uv ignore package versions published after that date:

# /// script
# dependencies = ["httpx"]
# [tool.uv]
# exclude-newer = "2025-04-17T00:00:00Z"
# ///

It is weaker than a lock. It does not pin exact versions, and it cannot restore a file that the index later removes.

Use a project instead when several files share dependencies, the code is importable as a package, tests need project state, or multiple scripts must move together.

Existing requirements.txt projects

A requirements.txt file can contain loose inputs, exact pins, hashes, constraints, indexes, URLs, or a resolver-generated requirements export. Its reproducibility depends on how it was produced and consumed. The filename alone says nothing.

You have two options: keep the pip format and run it with uv pip, or, for an application you own, migrate to uv.lock.

For a fully resolved file that lists direct and transitive dependencies, use uv’s pip-compatible interface without migrating:

uv venv
uv pip sync requirements.txt
.venv/bin/python app.py

Run the installed interpreter directly here. If a pyproject.toml is present, uv run can lock and sync its requirements before execution, replacing the packages you just installed. See uv’s script guide.

uv pip sync makes the environment match a fully resolved file. If requirements.txt contains only direct requirements, use uv pip install -r requirements.txt to resolve and install their dependencies. uv pip install -r is additive. See uv’s pip locking guide.

For an owned application, a staged migration can be useful:

uv init --bare
uv add -r requirements.in
uv add --group test -r requirements-test.in
uv lock
uv sync --locked

Run the complete test and deployment path before deleting old files. If a downstream system still expects pip format, derive it from the validated uv lock with uv’s export command:

uv export --format requirements.txt \
  --output-file requirements.txt

Do not maintain uv.lock and a hand-edited export as two competing resolutions.

Cache and private indexes

The uv cache improves repeated installs but remains disposable:

uv cache prune
uv cache clean

Prefer prune for routine cleanup. clean removes all cache entries and forces later downloads and builds.

A lockfile improves repeatability. It does not make dependencies trustworthy.

For a private index, put its name and URL in pyproject.toml and keep the credentials out of the file:

[[tool.uv.index]]
name = "internal"
url = "https://pypi.example.com/simple"

Pass the credentials through UV_INDEX_INTERNAL_USERNAME and UV_INDEX_INTERNAL_PASSWORD, or through keyring. uv builds the variable names from the index name. See uv’s index authentication docs.

Old commands and their uv equivalents

If you are moving from the older tools, this table maps each familiar command to the uv command that does the same job.

JobBeforeuv
Install Pythonpyenv install 3.12uv python install 3.12
New project and environmentmkdir proj && cd proj && python -m venv .venvuv init proj && cd proj && uv sync
Add a packagepip install pandas && pip freeze > requirements.txtuv add pandas
Compile pinspip-compile requirements.inuv lock
Upgrade one packagepip-compile --upgrade-package httpxuv lock --upgrade-package httpx
Run in the environmentsource .venv/bin/activate && python app.pyuv run app.py
Script with dependenciesa manual venv for one filePEP 723 block + uv run
Run a tool oncepipx run blackuvx black
Install a toolpipx install ruffuv tool install ruff
Reproducible CI installpip install -r requirements.txtuv sync --locked

A compact operating checklist

For each project:

  1. Define requires-python and direct dependencies in pyproject.toml.
  2. Separate published extras (--optional) from local dependency groups (--group).
  3. Commit uv.lock. Ignore .venv and cache state.
  4. Run project-coupled tools through the project environment.
  5. Use uv lock --check or --locked in CI.
  6. Test every target OS and architecture represented by the lock.
  7. Export compatibility formats only for named downstream consumers.
  8. Keep index credentials out of committed files. Pass them through environment variables or keyring.

I use uv as my default Python tool on macOS because one binary covers these jobs and keeps project and tool dependencies separate.

References