pytest Fixtures: Scopes, Teardown, and Setup
TestlandMay 24, 2026pytest fixtures explained: scopes, conftest.py, yield teardown, parametrized fixtures, and factory patterns - with complete runnable examples for pytest 9.x.

Copy-pasted setUp methods are the tell. The same database connection built fresh for every test, a tearDown that silently never fires when the test raises an exception, and a conftest that nobody dares touch because nobody knows what it does. These patterns show up in every test suite that started with good intentions and grew without a fixture strategy.
pytest fixtures are functions that provide test setup and teardown through dependency injection: declare a fixture as a test argument and pytest builds, runs, and cleans it up automatically. The framework behind them is everywhere. pytest pulled ~848 million downloads last month (as of May 2026), 53% of Python developers use it according to the JetBrains Python Developers Survey 2024 (30,000+ respondents, against unittest's 23%), and the official plugin list counts 1,745 plugins. This post covers fixtures from the basics through scopes, conftest.py, autouse, parametrization, and factory patterns - with a complete working example at the end.
pytest 9.x release status and adoption
Prerequisites
You need Python 3.10+ and a basic understanding of pytest test functions. Install pytest:
# pytest 9.0.3
pip install pytestThat's it. No extra plugins needed for any of the patterns in this post.
The real cost of setup code without fixtures
Here's what unmanaged setup looks like in a unittest-style class:
# pytest 9.0.3 - unittest-style setup (the painful way)
import unittest
import sqlite3
class TestUserDatabase(unittest.TestCase):
def setUp(self):
# Re-runs for every single test - even when tests share the same DB state
self.conn = sqlite3.connect(":memory:")
self.cursor = self.conn.cursor()
self.cursor.execute("CREATE TABLE users (id INTEGER, name TEXT)")
def tearDown(self):
# This never runs if setUp raises an exception.
# If the DB connection fails, your test data leaks.
self.conn.close()
def test_insert_user(self):
self.cursor.execute("INSERT INTO users VALUES (1, 'Alice')")
self.conn.commit()
result = self.cursor.execute("SELECT name FROM users").fetchone()
self.assertEqual(result[0], "Alice")Three things break this pattern at scale. First, setUp runs for every test regardless of whether that test needs a fresh database. A session-level resource (like a database schema) gets rebuilt hundreds of times. Second, tearDown is not guaranteed to run - if setUp itself raises, the teardown is skipped and you leak resources. Third, sharing setup between test classes requires inheritance, and inheritance hierarchies for test utilities become maintenance nightmares fast.
The pytest docs describe fixtures as offering "dramatic improvements over the classic xUnit style of setup/teardown functions", and that "a fixture provides a defined, reliable and consistent context for the tests." Both claims hold up in practice.
pytest fixtures: dependency injection by name
The core mechanic is simple. As the pytest fixture docs put it: "At a basic level, test functions request fixtures they require by declaring them as arguments."
# pytest 9.0.3 - basic fixture
import pytest
@pytest.fixture
def sample_user():
return {"id": 1, "name": "Alice", "email": "alice@example.com"}
def test_user_has_name(sample_user):
assert sample_user["name"] == "Alice"pytest sees sample_user in the test's argument list, finds the fixture with that name, calls it, and injects the result. No imports, no class inheritance.
Fixtures can depend on other fixtures the same way:
# snippet - UserRepository stands in for your data-access class
import pytest
import sqlite3
@pytest.fixture
def db_connection():
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (id INTEGER, name TEXT)")
yield conn
conn.close()
@pytest.fixture
def user_repo(db_connection):
# db_connection is injected automatically
return UserRepository(db_connection)
def test_repo_saves_user(user_repo):
user_repo.save({"id": 1, "name": "Alice"})
assert user_repo.find(1)["name"] == "Alice"The chain resolves automatically. If five fixtures form a dependency graph, pytest builds the full graph before running the test.
Yield fixtures: teardown that always runs
Return-based fixtures can't guarantee cleanup. Yield fixtures can. The pattern: put setup before yield, teardown after.
The pytest docs describe the execution order: "Once the test is finished, pytest will go back down the list of fixtures, but in the reverse order, taking each one that yielded, and running the code inside it that was after the yield statement."
# pytest 9.0.3 - yield fixture with guaranteed teardown
import pytest
import sqlite3
@pytest.fixture
def db_connection():
# SETUP: runs before the test
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (id INTEGER, name TEXT)")
conn.commit()
yield conn # the test receives the connection here
# TEARDOWN: runs after the test, even if the test fails
conn.execute("DROP TABLE IF EXISTS users")
conn.close()
def test_insert_and_query(db_connection):
db_connection.execute("INSERT INTO users VALUES (1, 'Alice')")
db_connection.commit()
row = db_connection.execute("SELECT name FROM users WHERE id=1").fetchone()
assert row[0] == "Alice"The teardown after yield runs regardless of whether the test passed, failed, or raised an unexpected exception. This is the fix for the tearDown-that-silently-skips problem.
Fixture scopes: how often setup runs
Every fixture has a scope that controls its lifetime. The five scopes, with their destruction points per the pytest docs:
| Scope | Destroyed | Typical use |
|---|---|---|
function (default) | "at the end of the test" | Isolated state per test |
class | At the end of the last test in the class | Shared setup within a test class |
module | At the end of the last test in the module | Expensive connections shared within a file |
package | At the end of the last test in the package | Shared state across a directory |
session | "at the end of the test session" | One-time global setup (compiled artifacts, DB schemas) |
The scope rule matters: pytest docs state that "it is fine for fixtures to use 'broader' scoped fixtures but not the other way round: A session-scoped fixture could not use a module-scoped one in a meaningful way." A function-scoped fixture requesting a session-scoped one is fine. The reverse direction fails: a session-scoped fixture cannot request a function-scoped fixture, because the session outlives the function, and pytest raises a ScopeMismatch error.
# pytest 9.0.3 - session-scoped fixture
import pytest
import sqlite3
@pytest.fixture(scope="session")
def db_schema():
# Runs once for the entire test session
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (id INTEGER, name TEXT)")
conn.commit()
yield conn
conn.close()Fair warning on session scope: any mutable state in a session-scoped fixture creates test-order dependencies. If test A inserts a row and test B assumes the table is empty, you'll see failures that only appear when tests run in a specific order - one of the hardest bugs to debug in a large suite.
Sharing fixtures across files with conftest.py
When multiple test files need the same fixture, conftest.py is the answer.
The pytest reference docs explain: "The conftest.py file serves as a means of providing fixtures for an entire directory." And: "Fixtures defined in a conftest.py can be used by any test in that package without needing to import them (pytest will automatically discover them)."
A typical project layout:
tests/
conftest.py <- shared fixtures (db_connection, sample_user, etc.)
test_users.py <- uses fixtures from conftest.py without importing
test_orders.py <- same
unit/
conftest.py <- fixtures scoped to the unit/ subdirectory only
test_validators.py# pytest 9.0.3 - conftest.py with shared fixture
import pytest
import sqlite3
@pytest.fixture(scope="session")
def db_connection():
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (id INTEGER, name TEXT)")
conn.execute("CREATE TABLE orders (id INTEGER, user_id INTEGER, total REAL)")
conn.commit()
yield conn
conn.close()The recommendation: don't move a fixture to conftest.py until at least two test files need it. Premature extraction makes it harder to understand which tests depend on which state - you end up reading three files instead of one to understand a single test.
Autouse fixtures: implicit setup, used sparingly
Autouse fixtures run automatically for every test in scope without being requested. The pytest docs describe them as "a convenient way to make all tests automatically request them."
# pytest 9.0.3 - autouse fixture
import pytest
import os
@pytest.fixture(autouse=True)
def reset_environment():
# Reset env vars before every test
original = os.environ.get("APP_ENV")
os.environ["APP_ENV"] = "test"
yield
# Restore original state
if original is None:
os.environ.pop("APP_ENV", None)
else:
os.environ["APP_ENV"] = originalAutouse is the right tool for cross-cutting setup that every test genuinely needs: resetting environment variables, resetting a shared clock, clearing a cache. It's the wrong tool for business-logic setup. If a fixture populates a database with user records and runs on every test, you'll spend time debugging failures that have nothing to do with users. The implicit nature of autouse means readers of any test file can't tell from the test code what state they're running in - that's the trade-off.
Parametrized fixtures: one fixture, many runs
Fixtures can accept parameters to run the same test logic against multiple configurations. Per the pytest docs: "Fixture functions can be parametrized in which case they will be called multiple times, each time executing the set of dependent tests."
The key difference from @pytest.mark.parametrize: fixture parametrization re-runs the entire fixture lifecycle (setup, test, teardown) for each parameter value. @pytest.mark.parametrize injects values into the test function but doesn't affect fixture scope.
# snippet - create_sqlite_connection / create_postgres_connection stand in for your DB setup
import pytest
@pytest.fixture(params=["sqlite", "postgres"])
def database(request):
db_type = request.param
if db_type == "sqlite":
conn = create_sqlite_connection()
else:
conn = create_postgres_connection()
yield conn
conn.close()
def test_insert_user(database):
# This test runs twice: once with SQLite, once with Postgres
database.execute("INSERT INTO users VALUES (1, 'Alice')")
row = database.execute("SELECT name FROM users WHERE id=1").fetchone()
assert row[0] == "Alice"pytest names each run automatically: test_insert_user[sqlite] and test_insert_user[postgres]. The full setup and teardown cycle runs for each parameter (connection opens, test runs, connection closes), making this safe for database connections that shouldn't be shared between configurations.
Factories as fixtures: dynamic test objects
Some tests need multiple instances of an object with different attributes. The factory-as-fixture pattern handles this. The pytest docs describe it: "The 'factory as fixture' pattern can help in situations where the result of a fixture is needed multiple times in a single test. Instead of returning data directly, the fixture instead returns a function which generates the data."
# snippet - can_list_all_users stands in for your permission check;
# db_connection comes from conftest.py with name, email, and role columns
import pytest
@pytest.fixture
def make_user(db_connection):
created_users = []
def _make_user(name, email, role="viewer"):
user = {
"id": len(created_users) + 1,
"name": name,
"email": email,
"role": role,
}
db_connection.execute(
"INSERT INTO users (id, name, email, role) VALUES (?, ?, ?, ?)",
(user["id"], user["name"], user["email"], user["role"]),
)
db_connection.commit()
created_users.append(user["id"])
return user
yield _make_user
# Cleanup: remove all users created during this test
for user_id in created_users:
db_connection.execute("DELETE FROM users WHERE id = ?", (user_id,))
db_connection.commit()
def test_admin_can_see_all_users(make_user):
alice = make_user("Alice", "alice@example.com", role="admin")
bob = make_user("Bob", "bob@example.com", role="viewer")
assert can_list_all_users(alice)
assert not can_list_all_users(bob)The cleanup pattern works like this: IDs get appended to a list inside the factory, and after yield those IDs are deleted from the database. Each test cleans up exactly the objects it created.
The trade-off: the indirection (a function that returns a function) adds cognitive load. It's worth it when a test genuinely needs multiple different instances with variation. It's overkill when every test just needs one standard user - in that case, a plain fixture returning a fixed object is clearer.
A complete example: testing a user-management module
This example uses three fixtures across two files, each demonstrating a different concept. Both files are complete and runnable as written.
# pytest 9.0.3 - conftest.py
import pytest
import sqlite3
# session scope: schema creation runs once per test session
@pytest.fixture(scope="session")
def db_connection():
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.execute(
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, role TEXT)"
)
conn.commit()
yield conn
conn.close()
# function scope: factory + cleanup per test
@pytest.fixture
def make_user(db_connection):
created = []
def _factory(name, role="viewer"):
conn = db_connection
conn.execute("INSERT INTO users (name, role) VALUES (?, ?)", (name, role))
conn.commit()
user_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
created.append(user_id)
return {"id": user_id, "name": name, "role": role}
yield _factory
for uid in created:
db_connection.execute("DELETE FROM users WHERE id = ?", (uid,))
db_connection.commit()# pytest 9.0.3 - test_users.py
import pytest
def get_permissions(user: dict) -> dict:
"""Return permission flags for a given user role."""
perms = {
"admin": {"can_read": True, "can_delete": True},
"editor": {"can_read": True, "can_delete": False},
"viewer": {"can_read": True, "can_delete": False},
}
return perms.get(user["role"], {"can_read": True, "can_delete": False})
# plain fixture request (dependency injection by name)
def test_user_exists_after_creation(make_user, db_connection):
user = make_user("Alice")
row = db_connection.execute(
"SELECT name FROM users WHERE id = ?", (user["id"],)
).fetchone()
assert row["name"] == "Alice"
# factory used twice in one test
def test_admin_sees_more_than_viewer(make_user):
admin = make_user("Admin", role="admin")
viewer = make_user("Viewer", role="viewer")
assert get_permissions(admin)["can_delete"] is True
assert get_permissions(viewer)["can_delete"] is False
# parametrized - role list runs the test for each value
@pytest.mark.parametrize("role", ["admin", "editor", "viewer"])
def test_all_roles_can_read(make_user, role):
user = make_user(f"User-{role}", role=role)
assert get_permissions(user)["can_read"] is TrueRunning with pytest -v produces output like:
test_users.py::test_user_exists_after_creation PASSED
test_users.py::test_admin_sees_more_than_viewer PASSED
test_users.py::test_all_roles_can_read[admin] PASSED
test_users.py::test_all_roles_can_read[editor] PASSED
test_users.py::test_all_roles_can_read[viewer] PASSEDThe db_connection session fixture creates the schema once. make_user creates and cleans up test data per-test. The parametrized test multiplies into three runs, each with its own factory invocation and cleanup.
Common mistakes that surface in real suites
ScopeMismatch at runtime. If a session-scoped fixture tries to request a function-scoped fixture, pytest raises ScopeMismatch immediately. The fix is usually to widen the dependency's scope or narrow the requesting fixture's scope. Read the error message - it names both fixtures.
Mutable session state causes order-dependent tests. A session-scoped fixture that accumulates state across tests is the most common cause of "fails in CI, passes locally" bugs. The test ordering in CI may differ from local. If a session fixture holds data that tests modify, wrap it with per-test cleanup or drop the scope to module or function.
Fixture overuse violates DAMP. This one is counterintuitive. Fixtures improve reuse, but over-applying them produces tests that are hard to understand without reading three other files. The Google Testing Blog (2019) argues: "In tests we can use the DAMP principle ('Descriptive and Meaningful Phrases'), which emphasizes readability over uniqueness." Ham Vocke on martinfowler.com makes the same case: "Don't try to be overly DRY. Duplication is okay, if it improves readability." A test that spells out its setup inline is sometimes the cleaner choice.
Invisible autouse state. An autouse fixture that modifies global state (environment variables, module-level singletons, monkey-patched functions) leaves every test running in modified conditions. If a test fails intermittently and you can't reproduce it without the full suite, look for autouse fixtures at the session or module scope.
Frequently asked questions about pytest fixtures
Can a fixture call another fixture?
Yes, by declaring the other fixture as an argument. pytest builds a full dependency graph and resolves it in the correct order before running the test. There's no limit on chain depth, but circular dependencies raise an error at collection time.
What happens when a fixture raises an exception?
If setup (the code before yield) raises, pytest marks the test as ERROR rather than FAILED, and that fixture's teardown code after yield does not run. Any fixtures that already completed their setup will still have their teardown called. Critical teardown belongs after the yield, or inside a try/finally wrapped around the yield when cleanup must run even if setup partially succeeds.
How is fixture parametrization different from @pytest.mark.parametrize?
Fixture parametrization (@pytest.fixture(params=[...])) re-runs the entire fixture lifecycle for each parameter value: setup, test, teardown. @pytest.mark.parametrize injects values into the test function without affecting fixture scope. Use fixture params when each value needs its own resource lifecycle (separate connections, separate containers). Use parametrize when you're varying test inputs against the same resource.
When should a fixture live in conftest.py?
Move a fixture to conftest.py when two or more test files in the same directory need it. Keep it in the test file until that threshold is reached. Premature extraction makes tests harder to read in isolation.
Do fixtures work with async tests?
Yes, with the pytest-asyncio plugin. Async fixtures use the same @pytest.fixture decorator but the fixture function is defined with async def and you configure the asyncio mode in pytest.ini or pyproject.toml. pytest-asyncio handles the event loop lifecycle.
Where fixtures fit as the suite grows
A suite with ten tests doesn't need much fixture discipline. Past a few dozen tests, fixture discoverability becomes the real bottleneck. Engineers stop understanding what's in conftest.py and start duplicating setup or skipping tests they can't understand.
The pattern that scales: organize conftest.py by concern (one for database setup, one for HTTP mocks, one for test users), and add a one-line docstring to every fixture explaining what it provides and what scope it uses. The docstring shows up in pytest --fixtures output, which makes the fixture inventory searchable.
Scope mismatches and shared mutable fixtures are also among the more common sources of flaky tests. If you're debugging unexplained flakiness, the fixing flaky tests guide covers isolation strategies in detail. For structuring the wider suite around these fixtures, the regression testing organization guide covers grouping and tagging patterns, and pytest markers are the natural complement to fixture scopes for running targeted subsets.
Take the next step with pytest
Run the complete conftest and test_users example above against your own data model. The fixture dependency graph will make more sense once you see it build and tear down in real output.
The official pytest fixture how-to docs cover advanced topics like fixture finalization via addfinalizer, fixture introspection with request, and overriding fixtures at different conftest levels. For the broader pytest workflow, the pytest markers post shows how to pair markers with scoped fixtures to run targeted subsets of the suite.