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, anduv runinside projects. I use 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. 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.
Projects: declarations, resolution, and environment
A uv project normally has three 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. 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
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.
# /// 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:
- 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. Fewer tools, and the four workflows stay separate. That is why I still use it as my default Python project tool on macOS.