Repository navigation
Internal AssertionError on stale _finalizers when a pytest_fixture_setup hookimpl raises during setup of a parametrized argument #14800
Description
Problem
If a pytest_fixture_setup hook implementation raises (including raising Skipped via pytest.skip()), the FixtureDef for the parametrized argument is left with a stale entry in self._finalizers. Every subsequent parameter for that same argument then fails during setup with an internal AssertionError at _pytest/fixtures.py:1221 instead of running.
The first parameter reports correctly. Only the ones after it break, so the damage is order-dependent and the reported failures point at the wrong parameters.
This is a regression in 9.1.0. 9.0.0 and 8.4.x are fine, and it still reproduces on main.
Minimal reproducer
No third-party plugins required.
conftest.py:
import pytest
@pytest.hookimpl(tryfirst=True)
def pytest_fixture_setup(fixturedef, request):
"""Resolve a fixture by name from inside pytest_fixture_setup, based on the
param value. This is what pytest-lazy-fixtures does to implement lf()."""
param = getattr(request, "param", None)
if isinstance(param, str) and param.startswith("fixture:"):
request.param = request.getfixturevalue(param[len("fixture:"):])test_pure.py:
import pytest
@pytest.fixture
def skipping_base():
pytest.skip("backend unavailable")
@pytest.fixture
def derived(skipping_base):
return "derived"
@pytest.mark.parametrize("val", ["fixture:derived", "plain-1", "plain-2"])
def test_thing(val):
assert isinstance(val, str)Run pytest.
Expected (and what 8.4.2 / 9.0.0 do): 2 passed, 1 skipped.
Actual on 9.1.0, 9.1.1 and main: 1 skipped, 2 errors, both errors being
# Register the pytest_fixture_post_finalizer as the first finalizer,
# which is executed last.
> assert not self._finalizers
^^^^^^^^^^^^^^^^^^^^
E AssertionError
.../_pytest/fixtures.py:1221: AssertionError
pytest.skip() is not special here — replacing it with raise RuntimeError("boom")
produces the same internal AssertionError on the following parameters (the
first parameter then reports the RuntimeError correctly, as it should).
Version bisect
| pytest | result |
|---|---|
| 8.3.5 | 2 passed, 1 skipped |
| 8.4.2 | 2 passed, 1 skipped |
| 9.0.0 | 2 passed, 1 skipped |
| 9.1.0 | 1 skipped, 2 errors |
| 9.1.1 | 1 skipped, 2 errors |
| main (9.2.0.dev133+g56b196e92) | 1 skipped, 2 errors |
Cause
Two commits that both landed in 9.1.0 encode contradictory assumptions about when _finalizers may be populated.
96728d56 ("fix pytest_fixture_post_finalizer sometimes called multiple times", #5848) added an early return to FixtureDef.finish() (_pytest/fixtures.py:1146):
def finish(self, request: SubRequest) -> None:
if self.cached_result is None:
# Already finished. It is assumed that finalizers cannot be added in
# this state.
return719f1ec9 ("improve handling of pytest_fixture_post_finalizer raising",
#14114) then made FixtureDef.execute() add a finalizer in exactly that state —
cached_result is still None at this point, because the hook that sets it has
not been called yet (_pytest/fixtures.py:1219):
# Register the pytest_fixture_post_finalizer as the first finalizer,
# which is executed last.
assert not self._finalizers
self.addfinalizer(
lambda: request.node.ihook.pytest_fixture_post_finalizer(
fixturedef=self, request=request
)
)
ihook = request.node.ihook
try:
result: FixtureValue = ihook.pytest_fixture_setup(
fixturedef=self, request=request
)
finally:
# Schedule our finalizer, even if the setup failed.
request.node.addfinalizer(finalizer)So the stated assumption in finish() — "finalizers cannot be added in this state" — is violated by execute() itself, on every fixture setup.
The failure sequence, confirmed by tracing addfinalizer / finish / execute on the val fixturedef:
execute()for param 1:_finalizersis empty, assert passes, the
pytest_fixture_post_finalizerlambda is appended →len(_finalizers) == 1,
cached_resultstillNone.ihook.pytest_fixture_setup(...)raises.cached_resultis never assigned.
Thefinallycorrectly schedulesfinishon the node.- At teardown, that scheduled
finish(request)runs — and hits the early
return, becausecached_result is None._finalizersis not drained. execute()for param 2:_finalizersstill has 1 entry andcached_result
isNone, so the cache branch is skipped andassert not self._finalizers
trips.
Trace output for the reproducer above, with FixtureDef.addfinalizer,
FixtureDef.finish, FixtureDef.execute and SetupState.addfinalizer
monkeypatched to log calls for argname == "val":
[execute] val: _finalizers=0 cached=False
[addfinalizer] val -> now 1
[SetupState.addfinalizer] node=test_thing[fixture:derived] fin=<bound method finish of <FixtureDef argname='val' ...>>
[finish] val (had 1 finalizers) <-- early-returns, does not drain
[execute] val: _finalizers=1 cached=False <-- assert trips
Relationship to #14775 / #14777
#14777 is the same family of bug — a setup failure before cached_result is set
leaves a stale finalizer and the next execute() trips the same assert — but its
fix does not cover this case. That fix moves resolve_fixture_function() and the
async check inside the try/except TEST_OUTCOME in the default
pytest_fixture_setup implementation, so the failure gets cached. Here the
exception comes from a different hookimpl that runs before the default
implementation (tryfirst), so the default implementation never executes and
nothing caches the failure. Verified: the reproducer above still fails on main,
which already contains that work.
Suggested fixes
Either would resolve it; the first seems closer to the intent of both commits.
- Make
finish()'s early return conditional on there being nothing to do, e.g.
if self.cached_result is None and not self._finalizers: return, so a
fixture whose setup failed still gets drained. This keeps Fixture with dependencies executespytest_fixture_post_finalizermultiple times #5848's
"post_finalizer not called twice" property, since the guard still short-
circuits the genuinely-already-finished case. - Register the
pytest_fixture_post_finalizerfinalizer only after
cached_resulthas been set, restoring the invariant thatfinish()
documents.
Either way it would be worth turning the assert not self._finalizers into a
real error path or dropping it, since as written any hook-raised setup failure
converts into an internal assertion with no indication of the real cause.
Real-world impact
Hit via pytest-lazy-fixtures, which implements lf() by resolving fixtures
inside pytest_fixture_setup exactly as the reproducer does. Any
@pytest.mark.parametrize list that mixes lf(...) with other values breaks
under 9.1.x as soon as the lazy fixture's dependency chain skips — which is the
normal case for integration tests that skip when a backing service is absent. In
our suite that turned 16 tests into internal AssertionErrors, attributed to
parameters that had nothing to do with the unavailable service.
Environment
pytest 9.1.1
Python 3.11.10
platform darwin
plugins: none required for the reproducer
Activity
Avoid pytest 9.1 because of regression pytest-dev/pytest#14800
Avoid pytest 9.1 because of regression pytest-dev/pytest#14800
Avoid pytest 9.1 because of regression pytest-dev/pytest#14800
ishiland commentedon Jul 31, 2026
Same defect reaches this from inside pytest itself, with no hookimpl and no third-party plugin. It may be worth folding into the repro set, since it means the blast radius is not limited to plugins that resolve fixtures from
pytest_fixture_setup.The built-in guard for a sync test requesting an async fixture calls
fail()frompytest_fixture_setup, above thetrythat would have setcached_result. That is the same state you describe:execute()has already registered a finalizer,finish()then early-returns oncached_result is Noneand never clears_finalizers, and the next test to request the fixture hits the assertion.Reproducer
One file, no conftest, no plugins.
Across versions
3 passedwith aPytestRemovedIn9Warningnaming the fixture on each test3 errors, each naming the fixturemain3 errors: the first names the fixture, the other two are bareAssertionErroratfixtures.py:12219.0.0 is the useful comparison. It errors on all three, which is intended after the deprecation landed, and every message tells you which fixture to fix. On 9.1.1 that diagnosis survives only for whichever test happens to reach the fixture first.
Why it is worth a look beyond the parametrize case
The failure count scales with how many tests share the fixture, and the surviving message is attached to the one test that was already going to fail. On a module-scoped
autouse=Truefixture the whole module inherits it.We hit this on a real suite under
xdist: one sync test placed in a class that inherited an async autouse fixture produced 951 errors on a single worker. Every one after the first was a bareAssertionErrorpointing into pytest internals, so nothing in the output named the fixture, the test, or the module that caused it. The count also made it read as an environment or database failure rather than a fixture-resolution error, which is what sent us looking in the wrong place first.Confirms your 9.1.0 boundary from a second direction. Environment: pytest 9.1.1, Python 3.14.5, macOS arm64.
Worked around a pytest bug
Cap pytest version to avoid pytest-dev/pytest#14800
Support pytest 9.1
Pass teardown error to pytest_fixture_post_finalizer explicitly