apps/docs/content/docs/guides/tools/python.mdx
Turborepo can discover packages across languages and toolchains. It can discover the members of a uv workspace as packages, add their dependency relationships to the Package Graph, and map common Turborepo tasks to uv commands. uv remains responsible for resolution, environments, and installation, and is the only supported Python package manager.
<Callout type="warn"> uv workspace support is experimental and may change. Use a version of `turbo` that recognizes `experimentalPythonWorkspaces` everywhere the repository runs, including local hooks and CI. Older versions reject unknown future flags. </Callout>Set the future flag in the root turbo.json:
{
"$schema": "https://turborepo.dev/schema.json",
"futureFlags": {
"experimentalPythonWorkspaces": true
},
"tasks": {}
}
To work with Python, the repository root must contain:
turbo and uv available on PATH (uv is required to run tasks; discovery works without it)pyproject.toml containing a [tool.uv.workspace] table[tool.turbo] name, used for a synthetic Turborepo package that represents the uv workspaceuv.lock[tool.turbo]
name = "acme-python"
[tool.uv.workspace]
members = ["packages/*"]
In the example above, members are defined as packages/*. Every matched non-root directory whose pyproject.toml declares [project].name becomes a package in the workspace, identified by its PEP 503-normalized project name. Member and exclude patterns must be relative to the repository, cannot contain .., and do not follow directory symlinks.
A dependency between two members, declared in [project.dependencies], [project.optional-dependencies], [dependency-groups], or legacy [tool.uv].dev-dependencies and resolved to the workspace via [tool.uv.sources], becomes an edge in the Package Graph, so filtering and affectedness calculations follow Python dependency relationships.
The synthetic workspace package uses [tool.turbo] name, depends on every member, and runs workspace-scoped tasks.
Every buildable member (one with [build-system] or [tool.uv] package = true) receives:
| Package | Turbo task | uv command |
|---|---|---|
| Buildable member | build | uv build --package=<name> |
Turborepo also discovers these quality tools and registers their qualified tasks:
| Declaration | Tasks | Tool invocation |
|---|---|---|
| Ruff | lint:ruff, format:ruff | ruff check, ruff format |
| Black | format:black | black |
| mypy | check:mypy | mypy |
| ty | check:ty | ty check |
| Pyright | check:pyright | pyright |
| pytest | test | pytest |
The canonical lint and check tasks fan out to every detected qualified task for that role. They are orchestration tasks and do not run a process themselves. Canonical format runs one formatter: Ruff takes precedence over Black. If both are declared in a selected scope, Turborepo warns and lists format:ruff and format:black so you can choose explicitly.
When no supported formatter or checker is detected for a role, Turborepo retains these fallbacks:
| Package | Turbo task | uv command |
|---|---|---|
| Member | format | uv format -- <member-dir> |
| Member | check | uv check --package=<name> |
| Workspace package | format | uv format -- <member-dirs...> |
| Workspace package | check | uv check --all-packages |
Turborepo detects direct, unconditional declarations in:
[project].dependencies[dependency-groups], including recursively included groups[tool.uv].dev-dependenciesIt does not detect tools declared only in [project.optional-dependencies] or declarations with an environment marker. Detection is intentionally limited to Ruff, Black, mypy, ty, Pyright, and pytest.
Root declarations are inherited one role at a time. If a member declares any tool for a role, its declarations replace the root declarations for that role. Otherwise, it inherits the root role. Ruff belongs to both the lint and format roles, so a member that declares Black replaces the root format role but can still inherit root Ruff for linting.
Pytest uses ownership rather than inheritance. A root declaration creates one repository-wide test task on the synthetic workspace package. A member declaration creates test only for that member; members do not inherit a root pytest declaration.
Turborepo remembers where a tool is declared and whether its dependency group is enabled by [tool.uv].default-groups. A non-default group is activated explicitly when the task runs.
If the same tool appears more than once in one manifest, a direct project
dependency wins, followed by the alphabetically first default group and then
the alphabetically first non-default group.
For each role, an unfiltered run uses the synthetic workspace package only when every member resolves the same tools with the same declaration owner and non-default activation group. Root-owned tools run once from the root. Homogeneous member-owned tools run once with --all-packages. If members resolve different tools or activation contexts, Turborepo runs the member tasks instead. A package filter always selects member-scoped commands.
For example, if every member declares Ruff, an unfiltered lint command is:
uv run --frozen --all-packages ruff check packages/py-api packages/py-lib
If py-api declares Black and py-lib declares Ruff, turbo run format instead selects both member entrypoints with their respective formatter.
An unfiltered test uses the root pytest task when one exists, even if members also declare pytest. Without a root declaration, directly configured member tests run in parallel. A package filter selects test only when that member declares pytest directly.
Detected tools run from the repository root with this layout:
uv run --frozen [owner] [group activation] <tool> [subcommand] [arguments] <member-dirs...>
--package <name>--all-packages--no-default-groups --group <group>For example:
uv run --frozen ruff check packages/py-api
uv run --frozen --package py-api black packages/py-api
uv run --frozen --package py-api --no-default-groups --group types mypy packages/py-api
uv run --frozen pytest
uv run --frozen --package py-api pytest packages/py-api
Detected quality-tool and fallback check commands run serially in the uv execution group. Build, fallback format, and member pytest commands can run in parallel. All mapped uv commands default to cache: false and run at the repository root. Detected-tool commands use --frozen. Turborepo does not invoke uv during discovery and never creates or updates uv.lock; refresh it explicitly with uv lock.
Turborepo passes the member directory to pytest instead of searching for tests itself. Pytest's own collection rules therefore cover conventional tests/ directories and colocated tests. The workspace command has no target so pytest can collect the repository-wide suite. Pytest's standard exit code 5 is preserved when a selected scope collects no tests.
Arguments after Turborepo's -- are passed to a command task. For detected quality tools, they are inserted before member-directory targets:
turbo run lint:ruff --filter=py-api -- --fix
# uv run --frozen --package py-api ruff check --fix packages/py-api
turbo run test --filter=py-api -- -k smoke
# uv run --frozen --package py-api pytest -k smoke packages/py-api
Canonical lint and check reject pass-through arguments because they may fan out to multiple processes. Run a package-qualified child shown in the error instead, such as turbo run py-api#check:mypy -- --strict. Canonical format accepts arguments because it resolves to one formatter command.
Arguments to build are appended to uv build --package=<name>. Any build argument makes automatic output detection unavailable because options such as --out-dir can relocate artifacts; configure outputs when you can describe them safely.
You can use the uv workspace or its members as entrypoints for filters:
# Execute builds for all toolchains
turbo run build
# Build one Python package's sdist and wheel
turbo run build --filter=py-api
# Format the entire uv workspace once
turbo run format
# Type check the entire uv workspace once
turbo run check
# Run the root pytest suite once, or directly declared member suites in parallel
turbo run test
Additionally, turbo query can be used to understand your repository's graphs and more.
All built-in uv command tasks default to uncached because the uv, Python, tool, and isolated build-backend identities are not yet represented in their hashes. You can opt in with an explicit cache: true only when your repository pins the relevant toolchain.
Turborepo creates task hashes using:
check, check:*, and testpyproject.toml, uv.toml, .python-version, ruff.toml, .ruff.toml, mypy.ini, .mypy.ini, pyrightconfig.json, pytest.ini, .pytest.ini, pytest.toml, .pytest.toml, setup.py, setup.cfg, tox.ini, ty.toml, and conftest.py, when presentuv.lock, scoped to each member. Root-owned tools conservatively include the workspace closureAutomatic inputs exclude .venv, .pytest_cache, .ruff_cache, .mypy_cache, .pyright, .ty, and __pycache__. Path-valued uv environment settings and active user or system uv configuration cannot yet be content-hashed safely, so they make automatic inputs untracked and disable caching unless you explicitly configure cache.
Project-specific hashing inputs must be accounted for manually. This includes:
env configurationinputs to define your own file inputs and $TURBO_DEFAULT$ to preserve zero-configuration file inputsFor a bare uv build, Turborepo detects the matching sdist and wheel in the workspace dist/ directory. Build arguments disable this inference. The .venv directory is never a task output; it remains uv's own materialized environment.
Changes to any pyproject.toml or the root uv.lock trigger workspace rediscovery. Watch mode ignores root .venv/ and dist/ events and known Python and quality-tool cache directories at the root and member scopes.
turbo prune <package> produces a self-contained partial workspace: the kept package directories, a uv.lock subset to the reachable closure (dependency groups and optional extras included, so uv sync --frozen succeeds), and a root pyproject.toml rewritten with the explicit kept member list. .python-version and uv.toml are carried over when present.
pyproject.toml without [tool.uv.workspace] is not modeled, and nested workspaces are not independently discovered.uv.lock; run uv lock to refresh and commit it. Use uv lock --check to validate it in CI. Turborepo rejects a missing lockfile and structural inconsistencies it can detect, but does not perform uv's complete manifest freshness validation during graph construction.uv.lock. Other local sources prevent graph construction because Turborepo cannot yet content-hash or prune them safely.turbo prune; prune a member instead.