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

I switched my Python workflow to uv because 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. A project, an inline-metadata script, a one-off CLI, and an installed CLI each have their own environment and lifecycle. This guide shows the commands I use and the boundary behind each one.

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 .

# Run a script that declares PEP 723 dependencies
uv run fetch.py

TL;DR. I use uv add, uv lock, uv sync, and uv run inside projects; 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:

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 boundaries

Projects: declarations, resolution, and environment

A uv project normally has three different artifacts:

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

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

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

If pyproject.toml and uv.lock disagree, these commands fail instead of resolving a new lock. This is usually the useful CI gate.

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

.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 that is truly required, 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 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:

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 the machine manifest intentionally owns it:

uv tool install 'ruff==0.12.0'
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.

Anatomy 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 compiled environment. 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, while 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:

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 unless the repository stores only non-secret references 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. That combination—fewer tools, without pretending every workflow is the same—is why I still use it as my default Python project tool on macOS.

References