Version v0.5.5 of the documentation is no longer actively maintained. The site that you are currently viewing is an archived snapshot. For up-to-date documentation, see the latest version.
ADR 0078: the dependency image is built from wheels without a daemon
Status: Accepted
Date: 2026-10-14
Relates: ADR 0073 (daemonless image builds, sections 2 and 4), ADR 0077 (dependency image and code layer), ADR 0076 (reuse by content label), ADR 0075 (the registry is the source of the digest), ADR 0003 (a DAG version is a dag.json and an image)
Issues: #1706
Context
ADR 0077 split a pushed build of the generated Dockerfile into a dependency image, found in the registry by its key, and a code layer appended through the registry API. When the dependency image is missing, the local daemon still builds it, and only projects whose Python dependencies are all pinned take the split, because the key covers the declared pins and not what pip resolves.
Path 3 of ADR 0073 builds the dependency image with no daemon and no emulation: the dependencies are installed as wheels for the target platform on the host and added as a layer to the runtime base. This record decides how, and where it departs from ADR 0073 section 4.
Decision
The dependency layer applies when a build splits (ADR 0077 decision 1), every platform is
linux/amd64orlinux/arm64, the project has nosystem_packages, every dependency is a requirement by name (no URL, path, VCS reference or inline option), the CLI does not run on Windows,uvis onPATHand finds a Python on the machine (uv python find; any version, uv needs one to run its resolver and installer, and the CLI runs it withUV_PYTHON_DOWNLOADS=never, so it never downloads one), and the base image is a Dexaflow runtime base for every platform: its config setsPYTHON_VERSION(X.Y.Z), it has/usr/local/lib/pythonX.Y/site-packages, its dpkg database lists glibc, and nothing in it tells pip where or what to install (decision 8). Apython_versionset indexaflow.yamlmust name the base’s X.Y. Otherwise the daemon builds the dependency image as ADR 0077 says, and the builder line says why the dependency layer does not apply, starting with the cause.Resolution, per platform. The CLI reads the base by digest, per platform image of its index, and lists the distributions of its site-packages from the
*.dist-infonames in the layer tar headers, applying the whiteouts of later layers. The listing is cached under the user cache directory by the platform image digest, so a redeploy reads no base layer. Then, in an empty directory:uv pip compile --no-config --quiet --python-platform <arch>-manylinux_2_36 \ --python-version X.Y.Z --only-binary :all: --generate-hashes --no-header \ --no-annotate -c base-constraints.txt -o resolved.txt requirements.inbase-constraints.txtpins every distribution of the base, so a dependency that needs another version of one fails the resolution, and uv’s reason reaches the builder line. uv runs with only the variables that locate programs, the home and cache directories, proxies and certificate authorities (UV_NATIVE_TLSandUV_SYSTEM_CERTSamong them); every otherUV_*andPIP_*variable is dropped, and--no-configignores the host’s uv configuration, so the host never changes what ships. The index is PyPI.The glibc ceiling is
manylinux_2_36. Debian 12 ships glibc 2.36 and Debian 13, the runtime base today, 2.41, so a wheel tagged up to 2.36 runs on both. A base with an older glibc gets the newest tag uv knows at or under its version.The key (format 2) is the SHA-256 of a canonical JSON document: the format, the installer and its version (
uv,uv 0.11.32), the digest of the base index or image, Python X.Y.Z, and, for every platform sorted, its wheel tag and the resolved lines (name==versionwith the sorted--hashvalues, sorted by name), and the image configuration the dependency image sets (PYTHONPATHwhen the project is not dbt, the user). The tag staysdeps-<first 32 hex>. Format 1 keys (ADR 0077) hash another document, so an image built from wheels and one the daemon built never share a tag.Unpinned dependencies take the split when the dependency layer applies: the key covers what uv resolved, so a new release that satisfies a range gives a new key and a new dependency image. This lifts ADR 0077 decision 5 for those builds. Without uv, or when the dependency layer does not apply, decision 5 holds as before.
Install and layer. The resolution minus what the base holds at the same version is installed with
uv pip install --target <dir> --python-platform ... --python-version X.Y.Z --only-binary :all: --require-hashes --no-deps. Everything in the directory butbin/lands in/usr/local/lib/pythonX.Y/site-packages/, andbin/*in/usr/local/bin/. A script whose shebang names a Python gets#!/usr/local/bin/pythonX.Y, and the RECORD lines of the scripts point at../../../bin/<name>with the new hash and size, sopip show -fandpip uninstallwork in the image. The layer is deterministic as the code layer is (ADR 0077 decision 6): sorted, owned by root, dated 1970-01-01, modes normalized. Only the directories created under site-packages and the files are entries, neverusr,usr/localor site-packages itself, which the base holds with its own modes; uv’s lock file is left out.The dependency image is the base for every requested platform (other platforms and attestation manifests of the base index dropped) with the layer appended,
PYTHONPATH=/home/leoflowset unless the project is dbt, and the user65532:65532when there are dependencies, as the generated Dockerfile sets them. It is pushed as an index when the base is one. The code layer is then appended as ADR 0077 decisions 3 and 7 say. The builder line saysdependency-layer (uv <version>), then code-layer, and how long the resolution took when that was a second or more.A base that configures pip declines. The daemon’s
pip installin the generated Dockerfile honors the pip configuration of the base: aPIP_*variable of its config that changes where or what pip installs (PIP_INDEX_URL,PIP_EXTRA_INDEX_URL,PIP_FIND_LINKS,PIP_CONSTRAINT, …, everyPIP_*but a short list that only changes how pip runs, such asPIP_NO_CACHE_DIR),XDG_CONFIG_DIRSorXDG_CONFIG_HOME, and apip.conf(orpip.ini) in any place pip reads one (/etc/pip.conf,/etc/xdg/pip/pip.conf,/usr/local/pip.conf,/root/.config/pip/pip.conf,/root/.pip/pip.conf; any file of that name outside site-packages counts). The dependency layer resolves from PyPI and would ship packages of the same name from another index, so the base listing records these and the dependency layer declines such a base. Auv.tomlin the image changes nothing: uv runs on the host, never in the image, and with--no-config.When the wheels fail after the decision (the install or the push of the dependency image fails, before the DAG image is written) and the dependencies are all pinned (the format 1 key of ADR 0077 cannot cover a range), the dependency image under that key is used: the one the registry already holds, or one the daemon builds when its tool is on
PATH. The output says why. Otherwise, and when the build was canceled, the build fails with uv’s error.
Where this departs from ADR 0073 section 4
- Locks are not read yet. Every build resolves; a project with a lock gets no faster path.
- uv only. uv resolves and installs for a target platform and Python version other than those of the Python it runs with, fast enough to run on every build; an installer choice is left for later.
- The glibc tag is a ceiling (decision 3), not the exact glibc of the base, so one wheel set serves the Debian 12 and Debian 13 bases.
- No bytecode. uv compiles nothing, and the host’s Python is another
version. Site-packages is owned by root, so the task process compiles every
module in memory on first import, in every process. Measured on
2026-10-14 with CPython 3.11.9, importing
pandas,requests,sqlalchemyandblack(20 distributions, 4,561 files) took 1.9 to 2.1 s without bytecode against 0.65 to 0.74 s with the bytecode pip writes. - One key covers the list of platforms, with a resolution per platform inside it, because the platforms are pushed under one tag as one index.
- No private index, local or VCS dependency yet. They decline to the daemon, which is why the index URL is not in the key.
- Data files of a wheel (
.data/headers,.data/data) land under site-packages, where--targetputs them, instead of/usr/local/includeand/usr/local/share. On the measured set the file lists of a pip install into/usr/localand of this layer were the same but for one C header ofgreenlet. - What else differs from pip in the image:
INSTALLERsaysuv, every distribution has aREQUESTEDfile (all are installed by name with--no-deps), the console scripts are uv’s (their shebang is rewritten to/usr/local/bin/pythonX.Y, so their hashes differ from pip’s), and the data files and headers are under site-packages as said above.
Consequences
- The first build of a key needs no daemon either: a deploy from a machine
or CI runner without docker builds and pushes everything, given uv, a
Python on that machine and PyPI, and builds
linux/arm64on anamd64host without emulation. - Every pushed build with uv runs a resolution, even when only DAG code changed, since the key needs it. It is a metadata request per package that uv’s cache does not hold; the builder line prints its time from one second.
- When PyPI cannot be reached, the resolution fails and the build declines to the daemon (pinned dependencies) or to the whole build (unpinned ones), with uv’s reason.
- A new release of a transitive dependency changes the resolution and so the
key: dependency images built from wheels pick up security updates on the
next build, without
--rebuild. Moredeps-*tags accumulate than with the daemon’s key. - A new uv version changes the key and rebuilds the dependency image once.
- The first import of each module costs its compilation in every task process (see above). Writing bytecode into the layer, with an interpreter of the base’s Python version, is a later step.
- uv is the user’s own binary from
PATH, trusted as the container tool of the daemon path is.