Description:
The python_uv workflow forces UV's cache into the per-build scratch directory, which is a fresh mkdir_temp() that SAM CLI deletes when the build finishes. UV therefore starts from a completely cold cache on every function, on every build, and can never reuse anything it downloaded before.
UvRunner._ensure_cache_dir() (packager.py#L98-L103):
def _ensure_cache_dir(self, config: UvConfig, scratch_dir: str) -> None:
"""Ensure UV cache directory is configured."""
if not config.cache_dir:
config.cache_dir = os.path.join(scratch_dir, "uv-cache")
if not os.path.exists(config.cache_dir):
self._osutils.makedirs(config.cache_dir)
That value is emitted as an explicit --cache-dir argument (utils.py#L131-L132), which also overrides any UV_CACHE_DIR the user has set. scratch_dir originates from osutils.mkdir_temp() inside SAM CLI's ApplicationBuilder._build_function(), so it is created and destroyed once per build definition.
This makes the effect twofold:
- Between functions in one
sam build — each function gets its own empty uv-cache, so shared dependencies (boto3, pydantic, …) are re-downloaded and re-unpacked once per function. Build time scales linearly with function count.
- Between successive builds — the scratch directory is deleted on exit, so the next
sam build starts cold again.
Both manifest dispatch paths are affected, since _handle_pyproject_build and _build_from_requirements both call UvRunner.install_requirements().
For contrast, python_pip never sets PIP_CACHE_DIR, so pip transparently uses the user-global ~/.cache/pip and stays warm across functions and builds. UV — whose main advantage is aggressive caching — is the only Python workflow deprived of it.
This is structurally the same class of problem as #900 (Rust workspace members each getting an isolated target/, defeating cargo's cache), with the additional penalty that the cache is discarded between builds and not just between functions.
Steps to reproduce:
- Create a SAM app with several Python functions that share dependencies, each using
BuildMethod: uv, e.g. a requirements.txt of:
boto3==1.35.0
pydantic==2.9.0
requests==2.32.3
- Run
sam build.
- Run
sam build again.
Every function in step 2, and every function again in step 3, performs a full cold-cache dependency download.
The behaviour can be reproduced against UV directly. Cold cache per invocation (what the workflow does today):
uv pip install -r req.txt --target ./t --cache-dir ./fresh-cache-per-build \
--python-version 3.12 --python-platform x86_64-unknown-linux-gnu
versus a cache that persists between invocations (proposed):
uv pip install -r req.txt --target ./t --cache-dir ./shared-cache \
--python-version 3.12 --python-platform x86_64-unknown-linux-gnu
Observed result:
Benchmarked with the three dependencies above, 5 iterations, medians reported. All paths on the same filesystem (xfs) to avoid tmpfs skew. UV 0.11.28.
requirements.txt path (_build_from_requirements)
| Scenario |
Median |
| Isolated cache, cold every build — current behaviour |
2622 ms |
| Shared cache, first build (cold) |
2664 ms |
| Shared cache, subsequent builds |
245 ms |
pyproject.toml path (_handle_pyproject_build)
| Scenario |
Median |
| Isolated cache, cold every build — current behaviour |
2963 ms |
| Shared cache, first build (cold) |
3023 ms |
| Shared cache, subsequent builds |
481 ms |
Multi-function build (one sam build, N functions sharing dependencies)
| Functions |
Isolated (current) |
Shared |
Wasted |
| 4 |
10436 ms |
3236 ms |
7.2 s |
| 8 |
20658 ms |
4051 ms |
16.6 s |
Current behaviour is strictly linear at roughly 2.6 s per function, with no variance across iterations because it is always cold (individual runs: 2610, 2610, 2622, 2627, 2663 ms). Each isolated build populates and then discards about 51 MB of cache.
Two further observations:
- Removing the redirect costs nothing on a cold start. Shared-cache first build (2664 ms) matches the isolated build (2622 ms) within noise, so no one pays more than they do today.
- No hardlink hazard. Files installed via
--target from a shared cache report nlink=1, i.e. UV copies rather than hardlinks for --target installs, so a persistent cache cannot be corrupted by later mutation of build artifacts.
Expected result:
UV should be able to reuse its cache across functions and across builds, so that only genuinely new or changed dependencies are downloaded.
Either of the following would achieve that:
- Drop
_ensure_cache_dir() and let UV use its own default global cache (~/.cache/uv). This matches what python_pip already does by not touching PIP_CACHE_DIR, and additionally restores the user's ability to point UV elsewhere with UV_CACHE_DIR. The trade-off is that builds then depend on user-global state — which is the trade-off pip already accepts.
- Default
cache_dir to a durable location such as .aws-sam/cache/uv rather than the ephemeral scratch directory, keeping the cache project-scoped while still persistent.
A related gap worth fixing alongside this: UvConfig is only ever instantiated bare — UvConfig() at actions.py#L44, packager.py#L129 and packager.py#L211 — and PythonUvWorkflow never passes a config into PythonUvBuildAction. Its cache_dir and no_cache fields are therefore unreachable, so there is currently no way for a user to work around this from a template. (See also #839, which asks for UV configuration to be exposed more generally; that request is about which UV flags are surfaced, whereas this issue is about the cache location and its performance cost.)
Additional environment details (Ex: Windows, Mac, Amazon Linux etc)
- OS: Amazon Linux 2023
- If using SAM CLI,
sam --version: 1.164.0 (aws-lambda-builders 1.65.0)
- AWS region: n/a — local build only
Additional: UV 0.11.28, Python 3.12
Description:
The
python_uvworkflow forces UV's cache into the per-build scratch directory, which is a freshmkdir_temp()that SAM CLI deletes when the build finishes. UV therefore starts from a completely cold cache on every function, on every build, and can never reuse anything it downloaded before.UvRunner._ensure_cache_dir()(packager.py#L98-L103):That value is emitted as an explicit
--cache-dirargument (utils.py#L131-L132), which also overrides anyUV_CACHE_DIRthe user has set.scratch_diroriginates fromosutils.mkdir_temp()inside SAM CLI'sApplicationBuilder._build_function(), so it is created and destroyed once per build definition.This makes the effect twofold:
sam build— each function gets its own emptyuv-cache, so shared dependencies (boto3, pydantic, …) are re-downloaded and re-unpacked once per function. Build time scales linearly with function count.sam buildstarts cold again.Both manifest dispatch paths are affected, since
_handle_pyproject_buildand_build_from_requirementsboth callUvRunner.install_requirements().For contrast,
python_pipnever setsPIP_CACHE_DIR, so pip transparently uses the user-global~/.cache/pipand stays warm across functions and builds. UV — whose main advantage is aggressive caching — is the only Python workflow deprived of it.This is structurally the same class of problem as #900 (Rust workspace members each getting an isolated
target/, defeating cargo's cache), with the additional penalty that the cache is discarded between builds and not just between functions.Steps to reproduce:
BuildMethod: uv, e.g. arequirements.txtof:sam build.sam buildagain.Every function in step 2, and every function again in step 3, performs a full cold-cache dependency download.
The behaviour can be reproduced against UV directly. Cold cache per invocation (what the workflow does today):
uv pip install -r req.txt --target ./t --cache-dir ./fresh-cache-per-build \ --python-version 3.12 --python-platform x86_64-unknown-linux-gnuversus a cache that persists between invocations (proposed):
uv pip install -r req.txt --target ./t --cache-dir ./shared-cache \ --python-version 3.12 --python-platform x86_64-unknown-linux-gnuObserved result:
Benchmarked with the three dependencies above, 5 iterations, medians reported. All paths on the same filesystem (xfs) to avoid tmpfs skew. UV 0.11.28.
requirements.txtpath (_build_from_requirements)pyproject.tomlpath (_handle_pyproject_build)Multi-function build (one
sam build, N functions sharing dependencies)Current behaviour is strictly linear at roughly 2.6 s per function, with no variance across iterations because it is always cold (individual runs: 2610, 2610, 2622, 2627, 2663 ms). Each isolated build populates and then discards about 51 MB of cache.
Two further observations:
--targetfrom a shared cache reportnlink=1, i.e. UV copies rather than hardlinks for--targetinstalls, so a persistent cache cannot be corrupted by later mutation of build artifacts.Expected result:
UV should be able to reuse its cache across functions and across builds, so that only genuinely new or changed dependencies are downloaded.
Either of the following would achieve that:
_ensure_cache_dir()and let UV use its own default global cache (~/.cache/uv). This matches whatpython_pipalready does by not touchingPIP_CACHE_DIR, and additionally restores the user's ability to point UV elsewhere withUV_CACHE_DIR. The trade-off is that builds then depend on user-global state — which is the trade-off pip already accepts.cache_dirto a durable location such as.aws-sam/cache/uvrather than the ephemeral scratch directory, keeping the cache project-scoped while still persistent.A related gap worth fixing alongside this:
UvConfigis only ever instantiated bare —UvConfig()at actions.py#L44, packager.py#L129 and packager.py#L211 — andPythonUvWorkflownever passes a config intoPythonUvBuildAction. Itscache_dirandno_cachefields are therefore unreachable, so there is currently no way for a user to work around this from a template. (See also #839, which asks for UV configuration to be exposed more generally; that request is about which UV flags are surfaced, whereas this issue is about the cache location and its performance cost.)Additional environment details (Ex: Windows, Mac, Amazon Linux etc)
sam --version: 1.164.0 (aws-lambda-builders 1.65.0)Additional: UV 0.11.28, Python 3.12