Summary
template/pyproject.toml.jinja pairs two settings that defeat each other:
[tool.uv]
# Fail fast on old uv versions rather than misbehave. 0.9 is the floor where
# exclude-newer accepts a relative duration like "14 days".
required-version = ">=0.9"
exclude-newer = "14 days"
On uv < 0.9 the relative duration fails to deserialize, so uv discards the entire [tool.uv] table — including required-version — emits a warning, and continues with defaults. The guard adopted specifically to catch these versions is in the table that fails, so it never runs.
The consequence is not a loud failure. A generated project on old uv resolves and locks with no cool-off at all, and the only signal is a warning that scrolls past above a successful command.
Reproduction
Minimal project with the template's exact [tool.uv] block, run under uv 0.8.17:
$ uv lock
warning: Failed to parse `pyproject.toml` during settings discovery:
TOML parse error at line 18, column 17
|
18 | exclude-newer = "14 days"
| ^^^^^^^^^
failed to parse year in date "14 days": failed to parse "14 d" as year ...
Using CPython 3.11.15 interpreter at: /usr/local/bin/python3
Resolved 1 package in 4ms
It resolves. uv sync and uv run behave the same way.
Isolating the cause
Same uv 0.8.17, varying only the [tool.uv] contents:
[tool.uv] contents |
result |
required-version only |
✅ error: Required uv version >=0.9 does not match the running version 0.8.17 |
required-version + exclude-newer = "2026-08-01T00:00:00Z" |
✅ same clean error |
required-version + exclude-newer = "14 days" |
❌ warning only, uv lock succeeds, no cool-off |
So the guard mechanism works. It is specifically the relative duration — the one setting required-version exists to protect — that suppresses it, because required-version can only be read if the table it lives in deserializes.
Which paths are affected
Not everything degrades silently, which is worth knowing when deciding how much to do:
| path |
uv < 0.9 behavior |
cool-off applied |
bare uv sync / lock / run / add (project default) |
warning, succeeds |
no |
make install etc. (UV_EXCLUDE_NEWER env var) |
hard error |
aborts |
documented uv add --exclude-newer "14 days" |
hard error |
aborts |
| generated-project CI |
unaffected — setup-uv pins 0.11.25 with a checksum |
yes |
The Makefile and explicit-flag paths fail loudly. The silently-degrading path is bare uv commands — which is exactly the case [tool.uv] was added to cover. From research-uv-changes.md:
The same [tool.uv] table sets exclude-newer = "14 days" so direct uv commands, not only Makefile and CI paths, inherit the policy.
That intent is right; it just doesn't hold on the versions required-version was meant to exclude.
Suggested fixes
Roughly in order of how well they hold, and all of these are your call on the churn tradeoff:
- Absolute
exclude-newer date. Verified above: required-version then fires correctly. Costs a periodic bump, though updating.md already defines a cadence and the template bumps pins each cycle anyway.
- A
uv --version preflight in the generated Makefile, plus documenting the floor. Catches make entry points with an actionable message, but does not protect bare uv commands.
- Upstream fix in uv — evaluate
required-version before rejecting unrelated keys in the same table, or degrade unparseable settings loudly rather than warning and continuing. This is arguably where it really belongs, since the trap is reachable by any project combining the two settings. Happy to file that separately if useful.
One approach worth ruling out
Moving required-version into a standalone uv.toml looks like it fixes it, and it does fire correctly on old uv — but on modern uv, the presence of uv.toml makes [tool.uv] be ignored entirely:
warning: Found both a `uv.toml` file and a `[tool.uv]` section in an adjacent `pyproject.toml`.
The following fields from `[tool.uv]` will be ignored in favor of the `uv.toml` file:
- required-version
- exclude-newer
Resolving despite existing lockfile due to removal of global exclude newer
That trades a silent failure on old uv for a silent failure on every version, so it is a step backwards.
Context
Found while debugging this in jlevy/fdu, which uses the same 14-day cool-off in a standalone uv.toml. That layout fails fatally rather than silently, so it presents differently: one stale uv breaks several unrelated-looking targets at once with a TOML date error that reads like a corrupt config. It cost a full investigation to attribute to the tool version. fdu's fix was option 2 — a uv-version preflight guarding the affected make targets, with the floor documented and cross-referenced to the CI pin (jlevy/fdu#21).
Verified against uv 0.8.17 (failing) and 0.12.4 (passing).
Summary
template/pyproject.toml.jinjapairs two settings that defeat each other:On uv < 0.9 the relative duration fails to deserialize, so uv discards the entire
[tool.uv]table — includingrequired-version— emits a warning, and continues with defaults. The guard adopted specifically to catch these versions is in the table that fails, so it never runs.The consequence is not a loud failure. A generated project on old uv resolves and locks with no cool-off at all, and the only signal is a warning that scrolls past above a successful command.
Reproduction
Minimal project with the template's exact
[tool.uv]block, run under uv 0.8.17:It resolves.
uv syncanduv runbehave the same way.Isolating the cause
Same uv 0.8.17, varying only the
[tool.uv]contents:[tool.uv]contentsrequired-versiononlyerror: Required uv version >=0.9 does not match the running version 0.8.17required-version+exclude-newer = "2026-08-01T00:00:00Z"required-version+exclude-newer = "14 days"uv locksucceeds, no cool-offSo the guard mechanism works. It is specifically the relative duration — the one setting
required-versionexists to protect — that suppresses it, becauserequired-versioncan only be read if the table it lives in deserializes.Which paths are affected
Not everything degrades silently, which is worth knowing when deciding how much to do:
uv sync/lock/run/add(project default)make installetc. (UV_EXCLUDE_NEWERenv var)uv add --exclude-newer "14 days"setup-uvpins 0.11.25 with a checksumThe Makefile and explicit-flag paths fail loudly. The silently-degrading path is bare
uvcommands — which is exactly the case[tool.uv]was added to cover. Fromresearch-uv-changes.md:That intent is right; it just doesn't hold on the versions
required-versionwas meant to exclude.Suggested fixes
Roughly in order of how well they hold, and all of these are your call on the churn tradeoff:
exclude-newerdate. Verified above:required-versionthen fires correctly. Costs a periodic bump, thoughupdating.mdalready defines a cadence and the template bumps pins each cycle anyway.uv --versionpreflight in the generated Makefile, plus documenting the floor. Catchesmakeentry points with an actionable message, but does not protect bareuvcommands.required-versionbefore rejecting unrelated keys in the same table, or degrade unparseable settings loudly rather than warning and continuing. This is arguably where it really belongs, since the trap is reachable by any project combining the two settings. Happy to file that separately if useful.One approach worth ruling out
Moving
required-versioninto a standaloneuv.tomllooks like it fixes it, and it does fire correctly on old uv — but on modern uv, the presence ofuv.tomlmakes[tool.uv]be ignored entirely:That trades a silent failure on old uv for a silent failure on every version, so it is a step backwards.
Context
Found while debugging this in jlevy/fdu, which uses the same 14-day cool-off in a standalone
uv.toml. That layout fails fatally rather than silently, so it presents differently: one stale uv breaks several unrelated-looking targets at once with a TOML date error that reads like a corrupt config. It cost a full investigation to attribute to the tool version. fdu's fix was option 2 — auv-versionpreflight guarding the affected make targets, with the floor documented and cross-referenced to the CI pin (jlevy/fdu#21).Verified against uv 0.8.17 (failing) and 0.12.4 (passing).