Your app works fine with one user. You open a second browser tab and the data is wrong. Your tests pass individually but fail when run together. The culprit: a global object created at module scope.

How it starts

I see this a lot in Python web projects:

# database.py
from sqlmodel import create_engine, Session

engine = create_engine("sqlite:///database.db")

def get_session():
    with Session(engine) as session:
        yield session

This 'innocent' engine is created the moment database.py is first imported. Every module that imports from database shares the same engine, the same connection pool, the same database file. For a simple script, this is fine. For a multi-module app, it creates hidden coupling and shared state.

The test isolation problem

I hit this recently in a FastAPI app:

# test_app.py
from myapp.database import engine, create_db_and_tables, clear_db_and_tables

@pytest.fixture(autouse=True)
def setup_database():
    clear_db_and_tables()
    create_db_and_tables()

def test_create_race(race_events):
    championship = create_races(2026, race_events)
    assert championship.id == 1  # Passes alone, fails in suite

That assert championship.id == 1 works when the test runs first. Run it after another test that inserts data, and the auto-increment ID comes back as 2. The fixture does its job, but state still leaks between tests in subtle ways: connection pool state, cached metadata, and SQLite's own bookkeeping on the shared file can carry over even with drop/recreate cycles.

The root cause is upstream: every test reaches for the same module-level engine pointed at the same on-disk database. If you want true isolation, the engine itself has to be per-test, not the cleanup ritual around it.

The fix is creating an engine per test session:

@pytest.fixture
def engine():
    engine = create_engine("sqlite://", echo=False)
    SQLModel.metadata.create_all(engine)
    yield engine
    engine.dispose()

@pytest.fixture
def session(engine):
    with Session(engine) as session:
        yield session

Now each test gets a fresh database (no scope defined on the fixture decorator means function scope = per test). No cleanup needed. No shared state.

Alternatively, keep the module-level engine and wrap each test in a transaction you roll back at teardown (sqlmodel's Session supports this).

If you omit engine.dispose() in the code above, you may see a ResourceWarning: unclosed database, but only when running pytest --cov. Coverage's sys.settrace() hook keeps frame locals alive longer, delaying GC of the engine.

The shared simulator problem

The database engine bug is about too much sharing. Here is the inverse: not enough sharing, which breaks in a different way.

Consider a race simulation dashboard. FakeDataSource wraps a RaceSimulator that holds the full mutable race state, driver positions, lap counter, cumulative changes, and advances it on each call:

class FakeDataSource(RaceDataSource):
    def __init__(self, data_file: Path, delay_ms: int = 100):
        drivers = self._load_drivers(data_file)
        self.simulator = RaceSimulator(drivers=drivers)  # mutable state lives here

    async def get_positions(self, fixture_id: str) -> list[Position]:
        self.simulator.tick()  # randomly swaps adjacent positions, advances lap
        return self.simulator.get_current_positions()

The FastAPI dependency looks like this:

def get_data_source() -> RaceDataSource:
    source_type = config("DATA_SOURCE", default="fake")
    if source_type == "fake":
        return FakeDataSource(data_file=..., delay_ms=...)  # new instance every call
    ...

def get_race_data_source() -> RaceDataSource:
    return get_data_source()

FastAPI calls get_race_data_source() once per request. Each browser tab that opens the SSE stream gets a brand new FakeDataSource with a brand new RaceSimulator starting at lap 1 with drivers in their initial order.

The random swaps then diverge independently: Tab A shows Verstappen in P1 at lap 12, Tab B shows Hamilton in P1 at lap 3. Neither reflects a shared reality, because there is no shared state at all.

Two fixes, from quick to idiomatic

1. cache: one line, works immediately

from functools import lru_cache

@lru_cache(maxsize=1)
def get_data_source() -> RaceDataSource:
    source_type = config("DATA_SOURCE", default="fake")
    if source_type == "fake":
        return FakeDataSource(...)
    return SportmonksDataSource()

One instance for the lifetime of the process. Simple, but hard to override in tests.

2. FastAPI app.state: idiomatic and testable

@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.data_source = get_data_source()
    yield

app = FastAPI(lifespan=lifespan)

def get_race_data_source(request: Request) -> RaceDataSource:
    return request.app.state.data_source

The data source is created once at startup, shared across all requests, and easy to replace in tests via app.dependency_overrides[get_race_data_source] = lambda: test_source.

Key takeaways

  • Module-level objects are created at import time and shared everywhere: global mutable state, and a common source of subtle bugs.
  • Tests that share a database engine aren't isolated, even with setup/teardown fixtures.
  • Web apps that create per-request instances lose shared state; apps that share module-level instances lose testability.
  • Use app.state or cache for shared runtime state; override the FastAPI dependency in tests for isolation.

The rule of thumb: if an object holds mutable state, pick its scope deliberately. Too broad (module scope) and tests leak into each other. Too narrow (per-request) and there's no shared reality. Match the scope to the object's intended lifetime.

Or put more sharply: any module-level object that holds mutable state or owns a resource (DB engines, HTTP clients, caches, queues, connection pools) should be encapsulated. Move it into a fixture, a Depends(), or app.state. Constants and pure values at module scope are fine; resources are not.

The cost of "just import it" is paid later, in test isolation, debugging, and concurrency. Under real concurrency the GIL hides this class of bug until it doesn't, see a race condition Rust wouldn't have let me write, where the same module-global pattern leaked one user's data into another user's response.