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, anduv runinside projects; PEP 723 metadata for self-contained scripts;uvxfor one-off tools; anduv tool installfor commands that must stay onPATH. In CI,--lockedverifies that project metadata agrees withuv.lock;--frozentrusts 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.
Projects: declarations, resolution, and environment
A uv project normally has three different artifacts:
pyproject.tomldeclares project metadata and direct requirements.uv.lockstores uv’s cross-platform resolution..venvis the local installed environment and should be disposable.
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
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.
# /// 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:
- Define
requires-pythonand direct dependencies inpyproject.toml. - Separate published extras from local dependency groups.
- Commit
uv.lock; ignore.venvand cache state. - Run project-coupled tools through the project environment.
- Use
uv lock --checkor--lockedin CI. - Test every target OS and architecture represented by the lock.
- Export compatibility formats only for named downstream consumers.
- 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.