A test run stops at collection with ScopeMismatch: You tried to access the function scoped fixture tmp_path with a session scoped request object. Nothing in the test changed; a fixture was widened to make the suite faster, and pytest refused the resulting graph. The error is not a bug in your test — it is pytest enforcing the one rule that keeps fixture lifetimes coherent: a fixture may only depend on fixtures whose scope is at least as wide as its own.
Prerequisites
- pytest 7.0+ (the error text below is from 7.x; the rule itself is unchanged since 3.0).
- Familiarity with fixture scopes and the resolution order covered in mastering pytest fixtures.
Solution
The error names both sides of the illegal edge. Read them out, decide which side is wrong, then apply one of exactly three repairs.
import pytest
# The failing arrangement: a session fixture reaching for a function-scoped one.
@pytest.fixture(scope="session")
def config_file(tmp_path): # tmp_path is function-scoped -> ScopeMismatch
path = tmp_path / "app.toml"
path.write_text("[app]\nname = 'demo'\n")
return path
Repair one — widen the inner fixture. Several built-in fixtures ship a session-scoped sibling precisely for this case: tmp_path has tmp_path_factory, and capsys has capsysbinary only at function scope but capfd is available at any scope through pytestconfig. When a factory exists, this is the correct fix, because the value really is shareable.
import pytest
@pytest.fixture(scope="session")
def config_file(tmp_path_factory): # the session-scoped factory
path = tmp_path_factory.mktemp("cfg") / "app.toml"
path.write_text("[app]\nname = 'demo'\n")
return path # one file, shared by the whole run
Repair two — narrow the requester. If the inner fixture is function-scoped because it must be (monkeypatch, tmp_path, caplog), and the outer fixture genuinely needs it, the outer fixture is the one in the wrong scope. Narrowing costs setup time, and that cost is the price of the isolation the inner fixture provides.
Repair three — split the fixture. The most common real answer: separate the expensive, immutable part from the per-test part. The expensive half stays session-scoped; a thin function-scoped wrapper adds whatever must not be shared.
import pytest
@pytest.fixture(scope="session")
def db_engine(): # expensive, immutable, shared
engine = create_engine("postgresql:///test")
yield engine
engine.dispose()
@pytest.fixture # cheap, per-test, isolated
def db_session(db_engine):
connection = db_engine.connect()
transaction = connection.begin()
yield Session(bind=connection)
transaction.rollback() # every test starts from the same state
connection.close()
That shape — a session-scoped resource plus a function-scoped transaction — is the standard solution for databases, HTTP clients, browser instances and anything else whose construction is slow but whose per-test state must be clean.
Why this works
pytest builds a directed graph of fixture dependencies before running anything, and each node carries a scope with a known lifetime. A session fixture is instantiated once and finalised after the last test; a function fixture is finalised after each test. An edge from wider to narrower would mean the session fixture holding a reference to an object that has already been torn down for every test after the first — so pytest rejects the graph at collection rather than producing a use-after-teardown at runtime. The check is purely structural, which is why it fires even when the specific test that triggered it would have worked.
Verification
--setup-show prints every setup and teardown with its scope letter, which makes the repaired lifetimes visible rather than assumed:
$ pytest --setup-show tests/test_orders.py -q
SETUP S db_engine
SETUP F db_session (fixtures used: db_engine)
tests/test_orders.py::test_create (fixtures used: db_engine, db_session)
TEARDOWN F db_session
SETUP F db_session (fixtures used: db_engine)
tests/test_orders.py::test_cancel (fixtures used: db_engine, db_session)
TEARDOWN F db_session
TEARDOWN S db_engine
One SETUP S at the top, one TEARDOWN S at the bottom, and a matched F pair around each test is the shape you want. A second SETUP S in the middle means the fixture is being rebuilt — usually because it is parametrized — and a missing teardown means an exception escaped the fixture body.
Reading the error when the edge is implicit
The error text is precise when you requested the fixture by name. It is much harder to read when the edge came from somewhere you did not write — an autouse fixture, a plugin, or a fixture that requests another fixture two levels down.
Three commands resolve it. pytest --fixtures -v lists every visible fixture with its scope and the file that defines it, which immediately identifies plugin-provided fixtures you did not know were function-scoped. pytest --setup-plan walks the intended setup order without executing anything, so a graph that fails to build is reported with the full chain rather than one pair. And --fixtures-per-test prints, for each test, exactly which fixtures it will use and where each comes from:
$ pytest tests/test_reports.py --fixtures-per-test
------------------------ fixtures used by test_monthly_report ------------------------
tmp_path -- .../_pytest/tmpdir.py:241
Return a temporary directory path object ...
report_dir -- tests/conftest.py:18
(no docstring)
db_engine -- tests/conftest.py:31
With the chain visible, the offending edge is usually one hop deeper than the error suggested: report_dir is session-scoped, it requests tmp_path, and nothing in the failing test mentions tmp_path at all.
A last diagnostic worth knowing: the scope of a fixture defined in a plugin cannot be changed from your conftest. Requesting caplog or monkeypatch from a wider fixture will always fail, and the fix is always on your side of the boundary — narrow the requester, or replicate the small amount of behaviour you needed with a module you control. Overriding the fixture name locally to widen it works, but it also replaces the plugin's implementation everywhere in that directory, which is rarely what anyone intends.
Edge cases and failure modes
- Parametrized fixtures multiply the setup, not the scope. A session fixture with
params=[...]is built once per parameter, so--setup-showlegitimately shows severalSETUP Slines. That is not a scope error, but it does mean any state you assumed was singular is not. autousedoes not change scope rules. An autouse function-scoped fixture requested implicitly by a session fixture raises the same error, and the traceback is harder to read because the edge is invisible in the source.- Async fixtures add a second scope to keep aligned. With
pytest-asyncio, the event loop has its own scope, and a session fixture on a function-scoped loop fails at teardown rather than at collection — see how to scope pytest fixtures for async tests for that variant. request.getfixturevaluebypasses the static check. Fetching a narrower fixture dynamically inside a wider one compiles and runs, then fails unpredictably at teardown. The static error exists for a reason; do not route around it.- Widening to fix a slow suite often backfires. A session-scoped fixture holding mutable state turns test order into a dependency, and the resulting failures appear in whichever test happens to run second. Measure with --durations and a profiler before assuming the fixture is the bottleneck.
Preventing the next one
Two conventions stop the error recurring as the suite grows. The first is to name fixtures by their lifetime when the lifetime is load-bearing: session_engine and db_session read differently at a glance, and a reviewer notices session_engine requesting tmp_path in a way they would not notice engine doing it.
The second is to keep the widening decision in one place. A module of shared fixtures where every session-scoped definition sits together makes the constraint visible — everything in that block may only depend on other things in that block. When a new fixture needs to join it, the question "is this immutable between tests?" is asked once, at the point where the answer matters, instead of being rediscovered from an error message weeks later.
Neither convention is enforceable by a tool, which is why the third safeguard is worth adding to CI: run pytest --setup-plan -q as a fast job. It builds the entire fixture graph for every test without executing a single fixture body, so a scope error introduced anywhere in the suite fails in seconds rather than after the slow tests have run.
Frequently Asked Questions
What does ScopeMismatch actually mean in pytest? It means a wider-scoped fixture requested a narrower-scoped one. A session fixture is built once and must outlive every function-scoped fixture, so pytest refuses to bind a value that will be torn down before the requester is finished with it.
Can I fix ScopeMismatch by making everything session-scoped? Only if the fixture is genuinely immutable between tests. Widening a mutable fixture removes the error and replaces it with cross-test state leakage, which is harder to diagnose than the original exception.
Why do tmp_path and monkeypatch cause ScopeMismatch?
Both are function-scoped by design and cannot be widened. Use tmp_path_factory for a session-scoped directory and set environment variables with os.environ plus explicit cleanup, or keep the requesting fixture function-scoped.
Related
- Mastering pytest fixtures — the resolution order and scope semantics this error enforces.
- How to scope pytest fixtures for async tests — the async variant, which fails at teardown instead of collection.
- Managing conftest hierarchies — where a shadowed fixture of the same name can change the scope you thought you had.
- Optimizing test discovery — the other half of a slow suite, usually a larger win than widening fixture scope.
← Back to Mastering Pytest Fixtures