Pytest & CI

Fixing ScopeMismatch Errors in pytest

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.

Python
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.

Python
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.

Python
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.

Choosing the repair for a ScopeMismatch A decision diagram: if a wider-scoped variant of the requested fixture exists, widen it; if the requested fixture must stay per-test and the value is mutable, narrow the requester; otherwise split the fixture into a shared immutable half and a per-test wrapper. Choosing the repair for a ScopeMismatch Is the requested fixture safe to share? a wide variant exists widen it tmp_path_factory same value, wider life it must stay per-test narrow the requester pay the setup cost isolation preserved partly shareable split in two session resource function-scoped wrapper Widening a mutable fixture trades a loud error for silent cross-test leakage.
The third branch is the usual answer for databases and clients: share the connection, isolate the transaction.

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.

Why the illegal edge cannot work A left-to-right timeline showing a session fixture created once, then three tests each creating and destroying their own function-scoped fixture, with the session fixture still holding a reference to the first one after it has been torn down. Why the illegal edge cannot work session setup built once test 1 function fixture lives test 1 teardown that fixture is gone test 2 session holds a dead ref pytest validates the graph at collection, before any fixture is built.
By the second test the session fixture would be holding an object that was finalised at the end of the first — the error prevents exactly that.

Verification

--setup-show prints every setup and teardown with its scope letter, which makes the repaired lifetimes visible rather than assumed:

Bash
$ 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.

Which fixture may depend on which A grid showing legal fixture dependencies: a function-scoped fixture may request any scope, a class fixture may request class, module and session, a module fixture may request module and session, and a session fixture may only request session-scoped fixtures. Which fixture may depend on which Criterion function module session function-scoped requester ok ok ok class-scoped requester error ok ok module-scoped requester error ok ok session-scoped requester error error ok
The rule is one-directional: a fixture may depend only on fixtures that live at least as long as it does.

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:

Bash
$ 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-show legitimately shows several SETUP S lines. That is not a scope error, but it does mean any state you assumed was singular is not.
  • autouse does 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.getfixturevalue bypasses 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.

← Back to Mastering Pytest Fixtures