V1 redesign: transaction lifecycle
This document describes the planned redesign of session and transaction management for the V1 release.
The short version: the repository stops deciding when to commit. A new
TransactionScope owns the transaction boundary, repositories resolve their session
from it per call, and the boundary can be attached to whatever lifecycle your
application already has — a FastAPI dependency, a CLI command, a worker handler, or a
plain with block.
Why change
Today there are three ways to manage a transaction, and which one you get is decided by a constructor argument:
SQLAlchemyRepository(bind=...) # a session per operation, repository commits
UnitOfWork(bind).transaction() # a session per block, unit of work commits
SQLAlchemyRepository(session=...) # undocumented; the caller owns everything
That choice silently changes the contract of the methods you call.
save() means two different things
With a bind, save() returns a model with its primary key populated. With an external
session it does not, because the repository calls session.add() without flushing:
with uow.transaction():
model = repo.save(my_model)
model.model_id # None
model.model_id # 2 - only after the block committed
Same method, same signature, same -> MODEL return type. Code cannot be written to work
in both modes. It also means an IntegrityError surfaces at the end of the transaction
rather than at the call that caused it.
Operations cannot be composed
Two save() calls against a bind-constructed repository are two transactions. If the
second fails, the first stays committed. There is no way to opt into atomicity without
switching to a different class entirely.
Some read methods committed
Fixed ahead of this redesign
This one was self-contained and shipped separately in #94. It is recorded here because it illustrates the underlying problem: the repository deciding when to commit. Under the new model it cannot recur, because the repository has no commit path at all.
The repository already had the mechanism to avoid this. _get_session(commit=False)
resolves to SessionHandler.get_session(read_only=True), which skips the commit, and
get() and get_many() used it correctly.
The three find variants simply never passed the flag, so they took the default
commit=True path and committed a read:
save(): BEGIN, FLUSH, COMMIT
get(): BEGIN <- commit=False, correct
get_many(): BEGIN <- commit=False, correct
find(): BEGIN, COMMIT <- flag not passed
paginated_find(): BEGIN, COMMIT <- flag not passed
cursor_paginated_find(): BEGIN, COMMIT <- flag not passed
The fix was to pass commit=False at those three call sites in each of the sync and
async repositories.
A unit of work registered once and used per request is broken
register_repository() captures a session at registration time, while transaction()
resolves one again at call time. Register at startup and use from a request, and they are
different sessions:
repo session is the transaction session: False
InvalidRequestError: Autobegin is disabled on this Session
This fails for UnitOfWork across threads and AsyncUnitOfWork across asyncio tasks —
which is the natural way to wire either into a web framework.
The async session registry grows without bound
AsyncSessionHandler scopes sessions on asyncio.current_task and never calls
remove(), so both the AsyncSession and the Task handle are retained forever:
after 50 requests: registry holds 50 sessions
after 100 requests: registry holds 100 sessions
after 150 requests: registry holds 150 sessions
The current documentation makes this worse
Components life cycle advises storing repositories in global variables, which is
exactly the usage that triggers the retention.
The new model
Three components, each with one responsibility.
SQLAlchemyBindManager engines, pools, config application lifetime
| transaction_scope(bind_name) ->
v
TransactionScope transaction boundary, application lifetime
current session (one per logical scope)
| constructor argument ->
v
SQLAlchemyRepository queries, model mapping application lifetime
no session state (singleton, injectable)
Bind manager
Unchanged in role — engines, pools, configuration, declarative bases, dispose_engines().
It gains two factory methods:
scope = sa_manager.transaction_scope() # sync bind
scope = sa_manager.async_transaction_scope("analytics") # async bind
Two methods rather than one returning a union, so the static type is known at the call
site. Each raises UnsupportedBindError if the bind type does not match.
Every call returns a new, independent scope
The manager does not cache scopes. Create them once at startup and hold the references. Calling the factory inside a request handler produces a scope with no active transaction.
Transaction scope
class TransactionScope:
@contextmanager
def transaction(self) -> Iterator[Session]: ...
@contextmanager
def savepoint(self) -> Iterator[Session]: ...
def current_session(self) -> Session: ...
transaction() yields the Session as a deliberate escape hatch for raw SQL and bulk
operations. It participates in the same transaction, so it composes rather than bypasses.
The current session is stored in a ContextVar, which isolates correctly across both
asyncio tasks and threads without any framework support.
Repository
The repository holds a scope, never a session, and follows one rule everywhere:
- never commit, begin, or close
- always flush on write, so the postcondition is identical in every context and integrity errors raise at the call site
- reads neither commit nor flush beyond SQLAlchemy's autoflush
def save(self, instance: MODEL) -> MODEL:
self._fail_if_invalid_models([instance])
session = self._scope.current_session()
session.add(instance)
session.flush()
return instance
SQLAlchemyRepositoryInterface and SQLAlchemyAsyncRepositoryInterface are unchanged.
They are what your dependency injection binds to, and this redesign does not touch them.
Transaction semantics
Commit only when something was written
with scope.transaction():
repo.save(user)
- clean exit with writes →
COMMIT - clean exit without writes →
ROLLBACK - exception →
ROLLBACK, exception propagates
Because SQLAlchemy cannot read without a transaction, read-only code paths still open one. Committing them is pointless, so the boundary detects whether any write actually happened and rolls back when none did.
How writes are detected
The obvious check does not work. Because repositories always flush,
session.new / dirty / deleted are empty at exit for a flushed write and for a pure
read — indistinguishable.
Detection therefore combines two signals:
| case | events | pending state at exit |
|---|---|---|
| read only | False |
empty |
save() + flush |
True |
empty |
raw session.execute(insert(...)) |
True |
empty |
session.add() with no flush |
False |
new=1 |
should_commit = (
wrote_via_events # after_flush / do_orm_execute
or session.new or session.dirty or session.deleted # unflushed escape-hatch writes
)
Nesting is refused
Calling transaction() inside an active transaction on the same scope raises
TransactionAlreadyActiveError. A block that silently joins claims ownership it does not
have, and presumes a transaction context the surrounding code never established.
Three rules follow:
- Boundaries belong to entry points — middleware, CLI commands, worker handlers — not to services. A service that opens its own boundary cannot be called from anywhere that already has one.
- An independent transaction means a second scope. Two scopes over the same bind hold separate sessions and separate transactions, which is how you run a subsystem in its own transaction while the request transaction is open.
- Partial rollback means
savepoint().
with scope.transaction():
repo.save(a)
with scope.savepoint():
repo.save(risky) # can fail without killing the request
Why savepoints are not optional sugar
A savepoint is a marker inside an open transaction. You can undo back to it without destroying the whole transaction:
BEGIN;
INSERT INTO users ...; -- (1)
SAVEPOINT sp1;
INSERT INTO audit_log ...; -- (2)
ROLLBACK TO SAVEPOINT sp1; -- undoes (2), keeps (1)
COMMIT;
Once a flush() fails, SQLAlchemy marks the transaction inactive and refuses further
work until something rolls back. So "try this, recover if it fails" genuinely requires a
savepoint — there is no other way to express it.
Concurrency
Threads and asyncio tasks behave differently, and the difference matters:
| sync scope | async scope | |
|---|---|---|
| context inheritance | none — each thread starts with a fresh context | child tasks inherit |
| concurrency guard | not needed | required |
Inheritance in asyncio is load-bearing: it is what allows a transaction opened in
Starlette middleware to reach a handler running in a child task. But SQLAlchemy does not
detect concurrent use of a single AsyncSession, so the async scope raises
ConcurrentSessionError when a second task begins an operation while another is in
flight:
async with scope.transaction():
await asyncio.gather(repo.get(1), repo.get(2)) # ConcurrentSessionError
Sequential use from a child task is fine, and a repository method calling another is re-entrant.
The sync scope needs no guard. A ThreadPoolExecutor worker calling a repository raises
NoActiveTransactionError, because its context is empty — a loud failure rather than a
silently wrong session.
New exceptions
| Exception | Raised when |
|---|---|
NoActiveTransactionError |
a repository method or savepoint() is called outside transaction() |
TransactionAlreadyActiveError |
transaction() is nested on the same scope |
ConcurrentSessionError |
two tasks operate on one session simultaneously |
Using it
Nothing in the design is framework-specific — the scope is a context manager.
With dependency injection
The application layer depends only on the repository protocols and never sees the transaction machinery:
# composition root, at startup
sa_manager = SQLAlchemyBindManager(config)
main = sa_manager.async_transaction_scope()
container.bind(UserRepositoryProtocol, UserRepository(main))
# application layer - knows nothing about scopes or sessions
async def create_user(users: UserRepositoryProtocol) -> None:
await users.save(User(name="Federico"))
FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
sa_manager.dispose_engines()
async def transaction():
async with main.transaction():
yield
app = FastAPI(lifespan=lifespan, dependencies=[Depends(transaction)])
lifespan= is the application lifecycle. dependencies=[...] is the request
lifecycle, despite living on the constructor.
Do not use @app.middleware("http") for the transaction boundary
Starlette's exception handling runs inside call_next, so a handler exception has
already been converted into a 500 response before the middleware sees it. The block exits
cleanly and commits a failed request.
A yield dependency has the exception thrown into it, so rollback works correctly.
Background tasks run after the response
The request transaction is already closed by then, so a repository call raises
NoActiveTransactionError. Background tasks must open their own scope.transaction().
Global dependencies also do not cover exception handlers or middleware, which run outside the dependency stack.
Synchronous applications
No asyncio required anywhere:
scope = sa_manager.transaction_scope()
repo = UserRepository(scope)
with scope.transaction():
repo.save(User(name="Federico"))
Threads are isolated automatically — each thread starts with a fresh context, so every thread must open its own transaction.
Extending the repository
Subclassing the repository to add custom methods is a supported use case, but until now
the API those methods need was never documented — the examples show a custom method with
an empty body, and _get_session() is not mentioned anywhere. V1 defines that contract.
Custom methods use self._session, plus the query helpers that build the statements the
built-in methods already use:
class ModelRepository(SQLAlchemyRepository[MyModel]):
_model = MyModel
def find_active(self) -> List[MyModel]:
stmt = self._find_query({"status": "active"})
return [x for x in self._session.execute(stmt).scalars()]
def archive(self, instance: MyModel) -> None:
self._fail_if_invalid_models([instance])
instance.status = "archived"
self._session.flush()
The supported members for subclasses:
| Member | Purpose |
|---|---|
self._session |
the session for the active transaction |
self._model |
the mapped class |
self._max_query_limit |
pagination ceiling, overridable |
self._find_query(search_params, order_by) |
build a filtered Select |
self._paginate_query_by_page(stmt, page, items_per_page) |
offset pagination |
self._cursor_paginated_query(...) |
cursor pagination |
self._count_query(stmt) |
wrap a query in a count |
self._model_pk() |
primary key column name |
self._fail_if_invalid_models(objects) |
reject models from another repository |
Everything else is internal and may change without notice.
Migrating existing subclasses
| Today | Under V1 | What you see |
|---|---|---|
with self._get_session() as s: s.add(x) |
commit moves to the boundary | works unchanged |
with self._get_session(commit=False) as s: |
behaviour unchanged | works unchanged |
| custom method called outside a transaction | NoActiveTransactionError |
loud failure |
self._external_session / self._session_handler |
removed | AttributeError |
_get_session() keeps working through the V1 deprecation window — it yields the current
session and emits a DeprecationWarning, so existing subclasses run unmodified while you
migrate them to self._session.
Will my custom write methods silently stop committing?
No. This is the natural worry, since _get_session() used to commit when the block
exited and the repository no longer commits at all.
A custom method that leaves a pending add() still gets committed, because the
transaction boundary inspects session.new / dirty / deleted before deciding. The write
is picked up there instead. Only the timing changes: the commit happens when the
surrounding transaction() block exits rather than when your method returns.
What does change loudly is calling such a method with no transaction open — that raises
NoActiveTransactionError instead of quietly committing on its own.
Migration
Nothing is removed in the V1 release. The legacy entry points keep working and emit
DeprecationWarning.
| Today | V1 | Next major |
|---|---|---|
Repository(bind=...) |
warns; autocommit per operation preserved | removed |
Repository(session=...) |
warns; session wrapped in an adapter scope | removed |
UnitOfWork / AsyncUnitOfWork |
warns; thin shim over TransactionScope |
removed |
register_repository() / repository(name) |
warns | removed |
SessionHandler / AsyncSessionHandler |
private already — removed | — |
RepositoryNotFoundError |
kept while register_repository lives |
removed |
The repository keeps its single rule — never commit, always flush. Legacy autocommit is reproduced by an adapter: when constructed from a bind, each call is wrapped in an implicit transaction, so the commit still happens at a boundary.
Reimplementing UnitOfWork as a shim over TransactionScope fixes the cross-task bug and
the registry leak on the deprecated path too, so users who have not migrated still get
the fixes.
Reads no longer commit
find(), paginated_find() and cursor_paginated_find() stopped committing in
#94, ahead of this
redesign. Called out because it is a behaviour change if you were relying on a read to
flush pending work.
Design decisions
Why not keep scoped_session as the registry?
scoped_session and async_scoped_session are registries, not what makes async work —
that is create_async_engine, async_sessionmaker and AsyncSession, none of which
change.
The decisive reason is the middleware pattern. Starlette's BaseHTTPMiddleware runs the
downstream application in a child task, so a registry keyed on asyncio.current_task
hands the handler a different session from the one the middleware opened, with no
transaction and no error:
middleware opens txn, handler runs in a child task
ContextVar -> handler sees middleware session: True
async_scoped_session -> handler sees middleware session: False
Fixing that means supplying a ContextVar-backed scopefunc, at which point the registry
is a ContextVar plus a proxy plus a remove() obligation, for the same result.
ContextVar also unifies the sync and async scoping models. scoped_session needs a
different scopefunc for each (thread-local versus current_task), which is why
SessionHandler and AsyncSessionHandler exist as separate classes today.
Why is there no read-only transaction?
An earlier draft included read_transaction(). Once the boundary commits only when
something was written, a read-only variant is redundant as an optimisation — and as a
guarantee it is advisory only, since it is enforced in Python.
If you need a genuine read-only guarantee, configure a second bind pointing at a read-only role or a replica. The database enforces it properly, and multiple binds are already supported.
Why must repositories always flush?
It makes the postcondition of save() identical in every context: the primary key and
server-side defaults are populated when the call returns, whatever provided the session.
It also fixes error locality. Without a flush, an IntegrityError surfaces wherever the
transaction happens to commit — often in framework code far from the cause. With a flush,
it raises at the offending line.
The cost is a round trip per write, and SQL ordering within a transaction becomes visible to the caller. That is the correct trade for a repository.
Limitations
Cross-bind atomicity remains unsupported. Two-phase commit is not implemented, so a transaction scope covers exactly one bind — the same limitation as today, now stated in one place rather than implied.