Typorithm · medium

Async Session ownership and implicit IO

pythonsqlalchemyalembicdatabasesindependent-practice
Saved on this device
Full lesson · 4 learning steps Open any exercise for focused practice
1

Step 1 of 4 · intro card

Async Session ownership and implicit IO

0%

Concept and contract

Give independent tasks their own Sessions and avoid unawaited reloads of expired ORM attributes.

AsyncIO changes how database IO is awaited, not the ownership rules of mutable Session state. An AsyncSession still represents one unit of work and should not be shared across concurrently executing independent tasks. A factory can be shared; a live Session and its transaction are different things.

The fixture models several independent read requests, not one Session for every row in a bulk table operation. Each task creates its own Session and looks up one requested ID. A temporary file holds the owned database, and the asynchronous Engine is explicitly disposed after the tasks finish.

Mechanism and worked example

The session factory uses expire_on_commit=False.

The session factory uses expire_on_commit=False. Each task loads a Role, commits its read unit and returns an ordinary value without attempting an implicit attribute reload afterward. Requested IDs [2, 1, 99] over scores [4, 9] yield values [9, 4, None] from three distinct Session owners.

The synchronous inspect_example wrapper runs a fresh event loop for this computer lab. Inside an already asynchronous application, await the asynchronous function instead of nesting asyncio.run in a running loop. For SQLAlchemy 2.1, install the asyncio extra so the required greenlet support is included alongside the chosen async driver.

Complete worked example

python

import asyncio
from pathlib import Path
from tempfile import TemporaryDirectory
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker

class Base(DeclarativeBase):
    pass

class Role(Base):
    __tablename__ = "roles"
    id: Mapped[int] = mapped_column(primary_key=True)
    score: Mapped[int]

async def read_owned(scores, requested):
    with TemporaryDirectory() as folder:
        engine = create_async_engine("sqlite+aiosqlite:///" + str(Path(folder) / "owned.sqlite3"),
                                     connect_args={"autocommit": False})
        maker = async_sessionmaker(engine, expire_on_commit=False)
        owners = []
        try:
            async with engine.begin() as connection:
                await connection.run_sync(Base.metadata.create_all)
            async with maker() as session, session.begin():
                session.add_all(Role(id=i, score=value) for i, value in enumerate(scores, 1))
            async def one(wanted):
                async with maker() as session:
                    owners.append(session)
                    role = await session.get(Role, wanted)
                    await session.commit()
                    return None if role is None else role.score
            tasks = [asyncio.create_task(one(wanted)) for wanted in requested]
            try:
                values = await asyncio.gather(*tasks)
            except BaseException:
                for task in tasks:
                    task.cancel()
                await asyncio.gather(*tasks, return_exceptions=True)
                raise
            return {"values": values, "sessions": len({id(owner) for owner in owners})}
        finally:
            await engine.dispose()

def inspect_example(scores, requested):
    return asyncio.run(read_owned(scores, requested))
Reasoning
The session factory uses expire_on_commit=False. Each task loads a Role, commits its read unit and returns an ordinary value without attempting an implicit attribute reload afterward. Requested IDs [2, 1, 99] over scores [4, 9] yield values [9, 4, None] from three distinct Session owners. The synchronous inspect_example wrapper runs a fresh event loop for this computer lab. Inside an already asynchronous application, await the asynchronous function instead of nesting asyncio.run in a running loop. For SQLAlchemy 2.1, install the asyncio extra so the required greenlet support is included alongside the chosen async driver.
Download the SQLAlchemy and Alembic computer lab ↗

Focused decision: context is in the complete example

python

maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as session:
    role = await session.get(Role, wanted)
    await session.commit()
Database decision
The factory creates a new Session for each independent task. Keeping loaded state unexpired avoids the demonstrated post-commit implicit reload; missing entities still return None.
Download the SQLAlchemy and Alembic computer lab ↗

Counterexamples and common mistakes

The controlled mistake enables expiration at commit and then reads role.score as an ordinary attribute.

The controlled mistake enables expiration at commit and then reads role.score as an ordinary attribute. That access needs a reload, but it is not an explicitly awaited database operation in this location. The pinned stack reports a MissingGreenlet cause wrapped by StatementError, rather than quietly returning a reliable stored value.

Do not fix this by sharing one Session among gather tasks or by assuming every property access is in-memory. Load required relationships explicitly, build a DTO while its data is available, or use documented awaited access. Keeping attributes unexpired is an explicit choice; it does not magically load attributes that were never fetched.

Controlled bug: observe the rejected database contract

python

import asyncio
from pathlib import Path
from tempfile import TemporaryDirectory
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker

class Base(DeclarativeBase):
    pass

class Role(Base):
    __tablename__ = "roles"
    id: Mapped[int] = mapped_column(primary_key=True)
    score: Mapped[int]

async def read_owned(scores, requested):
    with TemporaryDirectory() as folder:
        engine = create_async_engine("sqlite+aiosqlite:///" + str(Path(folder) / "owned.sqlite3"),
                                     connect_args={"autocommit": False})
        maker = async_sessionmaker(engine, expire_on_commit=True)
        owners = []
        try:
            async with engine.begin() as connection:
                await connection.run_sync(Base.metadata.create_all)
            async with maker() as session, session.begin():
                session.add_all(Role(id=i, score=value) for i, value in enumerate(scores, 1))
            async def one(wanted):
                async with maker() as session:
                    owners.append(session)
                    role = await session.get(Role, wanted)
                    await session.commit()
                    return None if role is None else role.score
            tasks = [asyncio.create_task(one(wanted)) for wanted in requested]
            try:
                values = await asyncio.gather(*tasks)
            except BaseException:
                for task in tasks:
                    task.cancel()
                await asyncio.gather(*tasks, return_exceptions=True)
                raise
            return {"values": values, "sessions": len({id(owner) for owner in owners})}
        finally:
            await engine.dispose()

def inspect_example(scores, requested):
    return asyncio.run(read_owned(scores, requested))
Reasoning
The controlled mistake enables expiration at commit and then reads role.score as an ordinary attribute. That access needs a reload, but it is not an explicitly awaited database operation in this location. The pinned stack reports a MissingGreenlet cause wrapped by StatementError, rather than quietly returning a reliable stored value. Do not fix this by sharing one Session among gather tasks or by assuming every property access is in-memory. Load required relationships explicitly, build a DTO while its data is available, or use documented awaited access. Keeping attributes unexpired is an explicit choice; it does not magically load attributes that were never fetched.
Download the SQLAlchemy and Alembic computer lab ↗

Independent practice

Type the factory and the per-task Session context first.

Type the factory and the per-task Session context first. For independent practice return a small dictionary containing ID and score for every found row, while preserving None for a missing ID. Keep each task’s ownership, the nonexpiring loaded state and awaited cleanup unchanged.

The task is a boundary-format change, not an invitation to share Session objects or return attached ORM instances. Test repeated requests for the same ID: they are still independent request units in this fixture. Preserve an empty result and zero Session owners when the request sequence itself is empty.

Step 1: Return an owned dictionary with ID and score for every found row; preserve None for a missing row. Step 2: Keep one Session context per independent task and preserve missing-row None.

Practice starter: solve before reading the solution

python

import asyncio
from pathlib import Path
from tempfile import TemporaryDirectory
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker

class Base(DeclarativeBase):
    pass

class Role(Base):
    __tablename__ = "roles"
    id: Mapped[int] = mapped_column(primary_key=True)
    score: Mapped[int]

async def read_owned(scores, requested):
    with TemporaryDirectory() as folder:
        engine = create_async_engine("sqlite+aiosqlite:///" + str(Path(folder) / "owned.sqlite3"),
                                     connect_args={"autocommit": False})
        maker = async_sessionmaker(engine, expire_on_commit=False)
        owners = []
        try:
            async with engine.begin() as connection:
                await connection.run_sync(Base.metadata.create_all)
            async with maker() as session, session.begin():
                session.add_all(Role(id=i, score=value) for i, value in enumerate(scores, 1))
            async def one(wanted):
                async with maker() as session:
                    owners.append(session)
                    role = await session.get(Role, wanted)
                    await session.commit()
                    return None if role is None else role.score
            tasks = [asyncio.create_task(one(wanted)) for wanted in requested]
            try:
                values = await asyncio.gather(*tasks)
            except BaseException:
                for task in tasks:
                    task.cancel()
                await asyncio.gather(*tasks, return_exceptions=True)
                raise
            return {"values": values, "sessions": len({id(owner) for owner in owners})}
        finally:
            await engine.dispose()

def inspect_example(scores, requested):
    return asyncio.run(read_owned(scores, requested))

# Task: Return an owned dictionary with ID and score for every found row; preserve None for a missing row.
Reasoning
Return an owned dictionary with ID and score for every found row; preserve None for a missing row.
Download the SQLAlchemy and Alembic computer lab ↗
  • Hint 1: Construct the dictionary at the existing primitive-return line.
  • Hint 2: Read only already loaded ID and score under the nonexpiring policy.

Solution, tests and trade-offs

The solution constructs the owned dictionary from the loaded object and returns it after the explicit commit under the same nonexpiring policy.

The solution constructs the owned dictionary from the loaded object and returns it after the explicit commit under the same nonexpiring policy. Acceptance cases compare complete dictionaries, missing-row None values and the number of independent Session owners. Returning ORM objects for a later serializer to traverse would not satisfy this stable data contract.

If one task fails, the program cancels and awaits its outstanding siblings before rethrowing the original failure; only then does finally await Engine disposal. This prevents a remaining task from acquiring a connection after cleanup. The examples use aiosqlite in a private fixture and do not establish another driver’s concurrency or cancellation behavior.

Practice solution and acceptance checks

python

import asyncio
from pathlib import Path
from tempfile import TemporaryDirectory
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker

class Base(DeclarativeBase):
    pass

class Role(Base):
    __tablename__ = "roles"
    id: Mapped[int] = mapped_column(primary_key=True)
    score: Mapped[int]

async def read_owned(scores, requested):
    with TemporaryDirectory() as folder:
        engine = create_async_engine("sqlite+aiosqlite:///" + str(Path(folder) / "owned.sqlite3"),
                                     connect_args={"autocommit": False})
        maker = async_sessionmaker(engine, expire_on_commit=False)
        owners = []
        try:
            async with engine.begin() as connection:
                await connection.run_sync(Base.metadata.create_all)
            async with maker() as session, session.begin():
                session.add_all(Role(id=i, score=value) for i, value in enumerate(scores, 1))
            async def one(wanted):
                async with maker() as session:
                    owners.append(session)
                    role = await session.get(Role, wanted)
                    await session.commit()
                    return None if role is None else {"id": role.id, "score": role.score}
            tasks = [asyncio.create_task(one(wanted)) for wanted in requested]
            try:
                values = await asyncio.gather(*tasks)
            except BaseException:
                for task in tasks:
                    task.cancel()
                await asyncio.gather(*tasks, return_exceptions=True)
                raise
            return {"values": values, "sessions": len({id(owner) for owner in owners})}
        finally:
            await engine.dispose()

def inspect_example(scores, requested):
    return asyncio.run(read_owned(scores, requested))
Reasoning
The solution constructs the owned dictionary from the loaded object and returns it after the explicit commit under the same nonexpiring policy. Acceptance cases compare complete dictionaries, missing-row None values and the number of independent Session owners. Returning ORM objects for a later serializer to traverse would not satisfy this stable data contract. If one task fails, the program cancels and awaits its outstanding siblings before rethrowing the original failure; only then does finally await Engine disposal. This prevents a remaining task from acquiring a connection after cleanup. The examples use aiosqlite in a private fixture and do not establish another driver’s concurrency or cancellation behavior.
Download the SQLAlchemy and Alembic computer lab ↗

Interview reasoning and next step

Explain the lifetime of the shared factory, each task’s Session and the Engine.

Explain the lifetime of the shared factory, each task’s Session and the Engine. Identify the exact post-commit attribute access that can need IO and why it lacks an await boundary in the broken example. Describe an alternative response-construction policy that avoids reading expired state altogether.

Async syntax alone is not evidence of safe concurrency. The transaction owner, loaded state, driver and cleanup behavior must still agree. Next, separate fresh-schema bootstrap from real schema evolution: changing model metadata does not upgrade an existing database or establish an Alembic revision history.

Optional stretch — after the core task works: Build the response before commit under another reviewed policy and compare the attribute-access boundaries.

Computer practice: https://applydjinn.com/typorithm/sqlalchemy-lab-20261107.zip. Install the pinned requirements in an isolated Python 3.13 environment and run verify.py. Edit the working exercise baseline, then run --practice sa-async. The lab uses disposable SQLite databases and actual SQLAlchemy/Alembic calls; it does not connect to application accounts, production databases or paid services. The app includes prepared results and explained questions without an editor or Python runtime.

Give independent tasks their own Sessions and avoid unawaited reloads of expired ORM attributes.