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

I switched my Python workflow to uv. It replaced the tool switching I used to do between pip, virtual environments, pip-tools, pipx, and project managers. One executable now covers most of that work.

The workflows are still different. For Python developers moving to uv on macOS from pip, virtual environments, pip-tools, or pipx, a project, an inline-metadata script, a one-off CLI, and an installed CLI each belong to a different environment and lifecycle. The commands below show how to choose among them.

Quick start

# Install uv with Homebrew
brew install uv

# Start a project
uv init example-app
cd example-app
uv add httpx
uv run python main.py

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

TL;DR. I use uv add, uv lock, uv sync, and uv run inside projects. I use PEP 723 metadata for self-contained scripts, uvx for one-off tools, and uv tool install for commands that must stay on PATH. In CI, --locked verifies that project metadata agrees with uv.lock. --frozen trusts the existing lock without checking freshness.

Install uv with one owner

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. Do not let two installers compete for the same executable.

Useful identity commands:

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.

The four uv workflow boundariesThe four uv workflow boundaries

Projects: declarations, resolution, and environment

A uv project normally has three artifacts:

  • 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.

Inputs and derived state in a uv projectInputs and derived state in a uv project

Create an application and add runtime and test requirements:

uv init forecast-app
cd forecast-app

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

Current uv application templates create main.py, pyproject.toml, README.md, and .python-version. They do not define a build system by default. Use uv init --lib for a packaged library with a src layout and build backend.

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.

Commit inputs, not the environment

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

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.

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. That failure is usually the CI gate you want.

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.

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.

Managed Python: a version request, not a universal binary

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. requires-python in pyproject.toml is the project’s compatibility contract. Keep both intentional:

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

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, and make CI select and report the interpreter explicitly.

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

Choosing a project, ephemeral, or persistent tool environmentChoosing a project, ephemeral, or persistent tool environment

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 .

Running that tool through uvx would isolate it from the project and may hide the installed package or plugins it needs.

Ad hoc tools: uvx

uvx is an alias for uv tool run. It creates 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 --from jupyterlab jupyter lab
uvx --with mkdocs-material mkdocs --help

Pin the tool version in CI or documentation. An unversioned first invocation selects a current release and later invocations may reuse cache state.

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 uninstall ruff

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

Scripts: make one file the release unit

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.

Anatomy of a PEP 723 scriptAnatomy of a PEP 723 script

# /// 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.

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 can constrain candidate distribution dates, but it is weaker than an exact locked resolution and does not guarantee an artifact remains available.

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.

Requirements files: compatibility, not failure

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.

Use uv’s pip-compatible interface without migrating:

uv venv
uv pip sync requirements.txt
uv run python app.py

uv pip sync makes the environment match the file. uv pip install -r is additive.

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, indexes, and supply-chain boundaries

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. Review package sources, index configuration, Git revisions, build backends, licenses, and credentials. Keep authenticated index configuration out of committed files. The exception is a non-secret reference to an approved credential mechanism.

For native packages, record the deployment architecture and test wheel availability. A resolver may otherwise fall back to a source build that needs compilers and system libraries absent from CI or production.

A compact operating checklist

For each project:

  1. Define requires-python and direct dependencies in pyproject.toml.
  2. Separate published extras from local dependency groups.
  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. Review source and credential policy independently of resolution.

uv is most useful when these ownership boundaries stay visible. One binary can manage them all without turning them into one environment. Fewer tools, and the four workflows stay separate. That is why I still use it as my default Python project tool on macOS.

References