transactional-boundary
Spring-style declarative transaction boundaries for SQLAlchemy — enforced at runtime, not by convention.
A write happened outside a transaction boundary (ORM write). Nothing will be saved.
where: services/orders.py:20 (create_order)
fix: Add @transactional to the function at the location above.
If this helper only runs inside an existing boundary, decorate its caller instead.
See: https://github.com/nohchang/transactional-boundary#boundaries
Most transaction decorators stop at "if you remember the decorator, it
commits". This package adds the other half: a runtime guard that makes
forgetting the decorator an error instead of a silent data loss. Every
write that reaches the database is checked against a declared boundary —
no boundary, no write, and a BoundaryError that names the function to fix.
Both session types are covered: Session through txbound, AsyncSession
through txbound.asyncio, with the same vocabulary in each — see
Async.
The problem
When session.commit() calls are scattered across a codebase, every
developer has to keep three things in their head at once:
- A missing commit is a silent loss.
session.add()without a later commit doesn't fail — the row just never appears, and nothing points at the function that forgot. - Catching an exception is not enough. After a database error, the
session is left in a failed state; forget the rollback and every later
operation dies with
PendingRollbackError, far from the actual bug. - Composing services multiplies commits. If
ServiceA.do()commits andServiceB.do()calls it before doing its own work, a failure in B leaves A's half of the unit permanently committed.
Declarative boundaries collapse those three into one rule: the function that does the work declares the transaction, and the library commits or rolls back.
Why not just decorators
Because a decorator alone doesn't fix the failure mode that matters. If
committing only happens where someone remembered @transactional, then a
write where nobody remembered it is still a silent loss — you've moved the
convention, not enforced it.
txbound installs an event guard on the session
(install_boundary_guard). A flush, a Core insert()/update()/delete(),
or a textual DML statement that executes outside a declared boundary raises
BoundaryError immediately, with the message shown above — and the rejected
state is disposed of, so it cannot ride along on a later, unrelated commit.
Install
pip install transactional-boundary
# for txbound.asyncio — adds SQLAlchemy's asyncio extra, which is what pulls
# greenlet in. On a machine SQLAlchemy does not list greenlet unconditionally
# for (an Apple Silicon Mac reports arm64, which is not on that list), the
# plain install has no greenlet and the first awaited operation inside a
# boundary raises about it. Pick your own async driver: asyncpg, aiosqlite, ...
pip install "transactional-boundary[asyncio]"
The import name is txbound (deliberately shorter than the PyPI name):
import txbound
Quick start
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
import txbound
from txbound import transactional
class Base(DeclarativeBase):
pass
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(primary_key=True)
label: Mapped[str]
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
SessionLocal = sessionmaker(bind=engine)
# Once, at startup. Only @independent_transaction needs this — it opens its
# own sessions. Register a sessionmaker, not a scoped_session.
txbound.configure(session_factory=SessionLocal)
class OrderService:
def __init__(self, session):
self.session = session
@transactional
def place(self, label: str) -> int:
order = Order(label=label)
self.session.add(order)
self.session.flush() # assigns order.id
return order.id
@transactional(read_only=True)
def count(self) -> int:
return self.session.query(Order).count()
# The entrypoint (HTTP handler, worker, test) creates the session, owns its
# lifetime, and installs the guard.
session = SessionLocal()
txbound.install_boundary_guard(session)
service = OrderService(session)
service.place("first") # committed when place() returns
assert service.count() == 1
session.close()
The session is found on self.session (a service method) or among the
positional arguments, searched left to right — so a classmethod, whose
leading positional is cls, works too. Either way it must arrive
positionally: keyword arguments are not searched, so place(session=s) is
invisible here.
The engine above is SQLite. It needs one extra piece of engine setup, but only if an outer boundary delegates to an inner one before writing anything of its own — which nothing above does, and neither does Composing services below. See SQLite: take BEGIN control away from the driver for the shape that does need it.
The vocabulary
Two decorators × one read_only flag. The axes are orthogonal: the
decorator decides where the session comes from, the flag decides
whether anything is committed. There are no combinations to memorize.
| write | read (read_only=True) |
|
|---|---|---|
@transactional |
joins an enclosing boundary if one exists — REQUIRED propagation (Spring's term for "reuse the caller's transaction, or start one if there is none") | commits nothing; a write inside it raises |
@independent_transaction |
always a separate session and transaction — REQUIRES_NEW propagation ("suspend nothing, just run my own transaction on the side") | a pure read outside any request scope |
Async
Install the extra — it is what brings greenlet in, and without it the first
awaited operation inside a boundary raises about greenlet rather than
anything to do with transactions:
pip install "transactional-boundary[asyncio]"
AsyncSession is then driven by a parallel pair of decorators:
from txbound.asyncio import independent_transaction, transactional
The vocabulary above is unchanged — two decorators × one read_only flag,
REQUIRED and REQUIRES_NEW, a joined inner boundary isolated in a savepoint, a
write inside a read-only boundary rejected. What differs is mechanical: the
decorated function is an async def, and its body is awaited inside the
boundary.
import asyncio
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
import txbound
from txbound.asyncio import transactional
class Base(DeclarativeBase):
pass
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(primary_key=True)
label: Mapped[str]
engine = create_async_engine("sqlite+aiosqlite://")
AsyncSessionLocal = async_sessionmaker(bind=engine)
# Once, at startup. Only @independent_transaction needs this — it opens its
# own sessions. Register an async_sessionmaker, not an async_scoped_session.
txbound.configure(async_session_factory=AsyncSessionLocal)
class OrderService:
def __init__(self, session: AsyncSession):
self.session = session
@transactional
async def place(self, label: str) -> int:
order = Order(label=label)
self.session.add(order)
await self.session.flush() # assigns order.id
return order.id
@transactional(read_only=True)
async def count(self) -> int:
return await self.session.scalar(select(func.count()).select_from(Order))
async def main() -> None:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# The entrypoint creates the session, owns its lifetime, and installs the
# guard — on the AsyncSession itself.
session = AsyncSessionLocal()
txbound.install_boundary_guard(session)
service = OrderService(session)
await service.place("first") # committed when place() returns
assert await service.count() == 1
await session.close()
asyncio.run(main())
install_boundary_guard takes the AsyncSession itself — it attaches to the
plain Session that session drives, which is where the ORM events fire. The
rest of the package root serves both worlds too — mark_read_only_entrypoint,
configure, BoundaryError; only the two decorators are duplicated.
The engine above is SQLite, under the same condition as the synchronous half:
the BEGIN-control setup is needed only if an outer boundary delegates to an
inner one before writing anything of its own, and for an AsyncEngine it
goes on engine.sync_engine. See
SQLite: take BEGIN control away from the driver.
configure has one slot per world, both keyword-only, at least one required:
txbound.configure(
session_factory=SessionLocal, # txbound's decorators
async_session_factory=AsyncSessionLocal, # txbound.asyncio's
)
The slots are independent — a call filling one leaves the other alone, so a codebase migrating to async can register them in two places without losing the first.
What is not supported
A synchronous decorator cannot drive an AsyncSession, and an async decorator
cannot drive a Session. Both directions raise rather than degrade, and
the message names the module to switch to. Left to run, the first commits
nothing behind an unawaited coroutine and the second dies with a TypeError
about awaiting None from inside this package — neither is a failure worth
inheriting. The function shape is checked the same way, at import time:
@transactional on an async def is refused (use txbound.asyncio), and
txbound.asyncio's decorators on a plain def are refused (use txbound).
Boundaries
Every runtime error links here. These are the rules the guard enforces:
- Every write needs a declared boundary. A flush, Core DML, textual
DML, or
bulk_*call outside@transactional/@independent_transactionraisesBoundaryError, and the rejected pending state is rolled away — it will not be committed later by an unrelated boundary. - Only the outermost boundary commits. An inner
@transactionaljoins the outer one (REQUIRED propagation) and runs inside a savepoint; see Composing services. - A read-only boundary commits nothing. A write inside
@transactional(read_only=True)raises — including when the read-only call has joined an enclosing write boundary; the writable flag is dropped for exactly that stretch and restored afterwards. Asession.add()that never flushed is caught at the end of the boundary instead of silently vanishing on the rollback. - An entrypoint can declare the whole session read-only.
mark_read_only_entrypoint(session)(say, for HTTP GET/HEAD/OPTIONS requests, or a report-only worker) makes any later attempt to open a write boundary on that session raise. - A boundary refuses to open over leaked state. Uncommitted changes that entered the session before the boundary opened are not this boundary's work; it raises rather than adopting them.
@independent_transactionrequires a separate session on a separate connection. Ascoped_sessionhands back the caller's own session; an engine usingSingletonThreadPoolorStaticPool(which is whatsqlite://gives you) hands back the caller's own connection. Either one silently defeats REQUIRES_NEW, so both raiseConfigurationErrorrather than committing the caller's work by accident — the scoped registry atconfigure(), before anything can run on it, and the shared pool on the first call.- Each world drives its own session type, loudly. The synchronous pair
drives a
Session,txbound.asyncio's drives anAsyncSession, and a decorator handed the other kind raises instead of degrading — with the message naming the module to switch to. A function whose body does not run when it is called is refused the same way, at import time: a generator or an async generator under either pair, and anasync defunder the synchronous one. The boundary would commit an empty session and close before the body ever ran, losing the write with nothing raised. See Async.
Under an AsyncSession, none of the above changes. Boundary state lives on
session.info, and an AsyncSession's info is not a copy of the Session
it drives — it is literally the same dict, so the two objects are one boundary
however you reach it. install_boundary_guard, handed the AsyncSession,
attaches its listeners to that inner Session, where all four write shapes
fire — including the bulk_* one, reached through
await session.run_sync(...). A boundary whose body is awaited opens when the
coroutine starts and settles when it returns, so it holds its transaction
across every await inside it; that is what the static checker's rule 7
(external-await-in-boundary) is about.
Every message carries a location. A guard event names the line that made
the write. A refusal raised as a boundary opens — a read-only entrypoint,
leaked state, the wrong kind of session — names path:line (Qualname) of
where the boundary is declared, and says so on the line, because a
decorator has no way to know its caller's line.
SQLite: take BEGIN control away from the driver
pysqlite and aiosqlite do not emit BEGIN until they see DML. An outer
@transactional that has issued no write of its own before its first
joined call therefore arrives at the inner boundary in autocommit mode: the
SAVEPOINT lands outside any transaction, its RELEASE commits outright, and
the outer's rollback then has nothing left to undo — the inner's write survives
the failure that was supposed to erase it. Rather than let rule 2 above fail
silently, that join raises ConfigurationError and hands you the fix:
from sqlalchemy import event
@event.listens_for(engine, "connect")
def _disable_driver_begin(dbapi_connection, record):
dbapi_connection.isolation_level = None
@event.listens_for(engine, "begin")
def _emit_begin(conn):
conn.exec_driver_sql("BEGIN")
For an AsyncEngine, listen on engine.sync_engine — Core events live on the
synchronous engine underneath. PostgreSQL and every other dialect need none of
this; their drivers open the transaction before the first statement.
The refusal is narrower than it sounds, and the scope is worth knowing:
- SQLite only, and a write join only. A read-only join writes nothing, so
its own
RELEASEloses nothing and it is never checked. - Only when the outer has written nothing of its own yet. A single
session.add(...)is already safe: opening the savepoint flushes first, and that flush is what starts the transaction. So is a Coreinsert()the outer has already executed. A query is not a write — an outer that ran aSELECTand then delegated is still refused, because aSELECTstarts no transaction on this driver, and the refusal says so.
Composing services
An inner @transactional does not commit — it joins the outer boundary, so
two services compose into one atomic unit:
class SignupService:
def __init__(self, session):
self.session = session
self.orders = OrderService(session)
@transactional
def register(self, name: str) -> None:
self.session.add(User(name=name))
try:
# joins register()'s boundary; runs inside a savepoint
self.orders.place("welcome gift")
except GiftUnavailable:
pass # optional step — signup must still go through
The joined inner call runs inside a savepoint. If place() fails and
register() swallows the exception, only what place() wrote is rolled
back — the User row commits normally. "The outer swallowed the inner's
failure" never turns into "the inner's partial write got committed".
This example runs as written on SQLite: register() adds the User before it
delegates, and that pending write is what starts the driver's transaction. An
outer that delegates before writing anything of its own needs the engine to
control BEGIN first — see
SQLite above.
Coming from Spring? This is the one place txbound deliberately differs. Spring's REQUIRED opens no savepoint (that's NESTED): a swallowed inner failure marks the whole transaction rollback-only, and the outer commit throws
UnexpectedRollbackException. txbound isolates the inner failure instead, and the outer commits normally. If you relied on Spring's "any inner failure poisons the unit" behaviour, re-raise instead of swallowing.
@independent_transaction
REQUIRES_NEW: the function always runs in its own session and its own transaction, committed (or rolled back) independently of whatever is happening outside. That is what you record failure reasons with — a write that must survive the rollback caused by the very failure it is recording:
class AuditLog:
@staticmethod
@independent_transaction
def record_failure(session, *, order_id: int, reason: str) -> None:
session.add(FailureRecord(order_id=order_id, reason=reason))
SQLAlchemy cannot park an ongoing transaction the way JPA can, so a new transaction means a separate session on a separate connection. Three things follow, and the decorator's enforced signature exists to keep you clear of them:
- The identity map differs — an ORM object loaded outside must not be passed in. Pass primitive ids.
- The outer transaction's uncommitted rows are invisible in here.
- Updating a row the outer transaction already locked is a deadlock.
The function must be shaped (session, *, keyword_only_args) — the
decorator creates the session and passes it as the first argument, and
everything else must be keyword-only. There is no shape in which a caller
can hand a session (or share one) — enforced at import time.
Two SQLite notes, because this is where the pattern is usually first tried.
In-memory SQLite is rejected outright. create_engine("sqlite://") uses
SingletonThreadPool, and poolclass=StaticPool is the usual recipe for
sharing one in-memory database across a test suite. Both hand the same
connection to every checkout, so the "separate" session would sit on the
caller's open transaction and its commit would commit the caller's
uncommitted work too — with the caller's later rollback then finding nothing
to undo. That is invisible, so txbound raises ConfigurationError instead.
Point tests that exercise @independent_transaction at a file
(sqlite:///./test.db) or a real database — and give that file engine the two
BEGIN-control listeners
(above) if the same suite
also composes boundaries.
SQLite locks the whole file, not rows. With a file-backed database, if
the outer transaction has flushed any write, an independent transaction's
write blocks on it and dies with database is locked — even on unrelated
tables. That is SQLite's single-writer model, not a row conflict; on
PostgreSQL the same code runs fine. Develop this pattern against the engine
you deploy on.
lock_timeout="3s" is the default on PostgreSQL, so consequence 3 fails
fast with a lock error instead of hanging until the pool times out. Pass
lock_timeout=None for paths that are supposed to wait (a batch
serializing behind pg_advisory_xact_lock — PostgreSQL's lock_timeout
applies to advisory-lock waits too). On other databases the default is
silently skipped; an explicitly passed value warns instead of pretending.
read_only=True works as everywhere else, with one extra rule: return
plain values, not ORM entities. The session closes on return and a
read-only session's objects expire on rollback, so a returned entity raises
DetachedInstanceError at first attribute access. (The write side returns
entities fine — expire_on_commit is off.)
In txbound.asyncio it is the same decorator awaited:
from txbound.asyncio import independent_transaction
class AuditLog:
@staticmethod
@independent_transaction
async def record_failure(
session: AsyncSession, *, order_id: int, reason: str
) -> None:
session.add(FailureRecord(order_id=order_id, reason=reason))
It opens its session from the async_session_factory slot, and refuses the
same two setups for the same reasons: an async_scoped_session hands back the
caller's own session, and a shared-connection pool hands back the caller's own
connection — which is what an in-memory sqlite+aiosqlite:// URL gives you
(StaticPool). Both raise ConfigurationError; the scoped registry is refused
by configure() itself, so it never reaches a boundary.
Framework integration
txbound deliberately ships no framework glue — the seam is small enough to own. FastAPI:
from fastapi import Depends, Request
from sqlalchemy.orm import Session
from typing import Annotated, Generator
import txbound
READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
def get_db_session(request: Request) -> Generator[Session, None, None]:
session = SessionLocal()
txbound.install_boundary_guard(session)
if request.method in READ_ONLY_METHODS:
txbound.mark_read_only_entrypoint(session)
try:
yield session
finally:
session.close()
DbSession = Annotated[Session, Depends(get_db_session)]
The async dependency is the same four calls, awaited where they have to be:
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession
async def get_async_db_session(
request: Request,
) -> AsyncGenerator[AsyncSession, None]:
session = AsyncSessionLocal()
# The AsyncSession itself — the guard attaches to the Session it drives,
# and mark_read_only_entrypoint writes to the info dict they share.
txbound.install_boundary_guard(session)
if request.method in READ_ONLY_METHODS:
txbound.mark_read_only_entrypoint(session)
try:
yield session
finally:
await session.close()
AsyncDbSession = Annotated[AsyncSession, Depends(get_async_db_session)]
Flask is the same three calls in a before_request/teardown_request pair
(create + guard on request start, close() on teardown), with
mark_read_only_entrypoint keyed on request.method.
A worker or scheduler is even smaller: create the session, install the guard, call the service, close. If a worker only ever reads, mark it read-only at the top and any write that sneaks into it becomes an error instead of a surprise.
Static checks
The runtime guard cannot see code that never runs, and it structurally cannot see writes that bypass the session's event hooks. The checker covers that ground:
python -m txbound.check # or: txbound-check
| # | rule | flags | needs config |
|---|---|---|---|
| 1 | manual-session-control |
session.commit() / rollback() / close() called directly — the function decides commit timing itself |
— |
| 2 | missing-boundary |
a function that writes has no boundary declared — an ORM write, a Core insert()/update()/delete() through session.execute() (chained calls included), a literal execute(text("INSERT ...")), or a bulk_* call |
scan_roots |
| 3 | independent-orm-param |
an @independent_transaction function takes an ORM model parameter |
model_patterns |
| 4 | unguarded-session |
a session created with no install_boundary_guard |
session_factories |
| 5 | raw-connection |
engine.connect() / session.connection() — bypasses the session, invisible to the runtime guard |
— |
| 6 | swallow-in-boundary |
a DB exception swallowed inside a write boundary — it leaves the session poisoned. Exempt when a savepoint is in play (the try wraps an explicit session.begin_nested(), or sits inside one — async with in the async world), or when the try's only database call is to a declared write boundary (joined or independent — each isolates its own failure) and nothing in it touches the boundary's own session |
— |
| 7 | external-await-in-boundary |
an async boundary awaits something that is not its own session (an HTTP call, a queue publish) — the connection is held open for the whole round trip. A call to a write boundary declared anywhere in the scanned tree is exempt | — |
| 8 | unawaited-session-call |
an AsyncSession call whose await was forgotten, so the call never runs — for execute(...) the write is lost while the caller sees success; for flush() inside a boundary the commit still saves the row, but the id that call should have fetched is not there |
— |
| — | unresolved-receiver |
x.commit() / x.connection() where the checker cannot tell whether x is a session — reported by rules 1 and 5 instead of being passed over silently; annotate the name, or list it in unresolved_allowlist |
— |
| — | unused-ignore |
an ignore comment that suppresses nothing, or names no rule | — |
Upgrading from 0.2.0? Several rules report where they did not, so a build that was green can go red on unchanged code. The two with the most to say have callouts below; the rest, one line each:
- rule 2 catches two write shapes it used to miss — a chained Core DML call
(
execute(insert(...).values(...)), and the same forupdate/delete) and a literalexecute(text("INSERT ...")). It also stops reporting a function whose boundary decorator arrived under an import alias. - rule 3 resolves that same alias, so an
@independent_transactiontaking an ORM parameter is now reported wherefrom txbound import independent_transaction as sync_itused to hide it. - rule 7 exempts a call to a boundary declared anywhere in the scanned
tree, so composed-service calls and
@independent_transactioncalls stop being reported as external round trips. The exemption is harvested from write boundaries only, soawait self.repo.get(id)against a@transactional(read_only=True)accessor now reports where it did not. - rule 6 exempts a swallow whose only database call is to a declared write boundary, and correspondingly no longer exempts one around a read-only inner call, or one that also touches the boundary's own session directly.
- rule 8's message for
flush()no longer says the write is lost — inside a boundary the row is still saved by the commit, and what breaks is the id that call was supposed to fetch.
A # txbound: ignore[rule-name] comment is the escape for any finding that is
wrong on your code. An annotation is not a general one: it helps only where the
checker could not tell that a receiver is a session — annotating
self.repo: ReadRepo does not silence rule 7, and rules 2 and 3 are answered
by a boundary and by a scalar parameter respectively, not by a type.
Rule 8 — a forgotten
await. What it reports is a real silent write loss:session.execute(...)withoutawaitbuilds a coroutine and throws it away, so the statement never runs, the boundary commits an empty transaction, and the caller sees success. The runtime guard structurally cannot see it — there is no statement for it to observe — which is why it lives here.# txbound: ignore[unawaited-session-call]is the local escape if a finding is wrong.Its coverage stops where the checker stops being sure. The receiver has to resolve to an
AsyncSession— a parameter or an attribute annotated with it, including through an alias like theAsyncDbSessionabove: a module-level alias is followed when the module defines it or imports it by name (from .deps import AsyncDbSession), across a chain of aliases and through a re-export in an__init__.py. Three things are not followed: a name the module neither defines nor imports, a name the module binds itself (its ownclass,defor assignment always wins), and a dotted reference (import app.deps, thendb: app.deps.AsyncDbSession). The result must also be discarded inside anasync def. That alias resolution is not specific to this rule — rules 1, 2, 5 and 7 read receivers through the same table.A receiver that cannot be shown to be specifically an
AsyncSession— including one that only comes from a factory call (db = AsyncSessionLocal()) — is not reported: a synchronousSession'sflush()/delete()returnNoneand have already run, so flagging those would be noise. The rule fails toward silence, so a clean run is not proof that every forgottenawaitis gone.
Rule 7 — renamed from
async-boundary. Before 1.0.0 it flagged any boundary on anasync def. That shape is now supported, so the rule was re-aimed at the hazard it had been standing in for, and renamed with it. Some builds that were red go green. But a# txbound: ignore[async-boundary]comment carried over from 0.2.0 now names a rule that no longer exists, and an ignore naming an unknown rule is reported asunused-ignoreand exits 1 — on its own, with no other violation in the file. Delete it if it was excusing a boundary on anasync def; rename it toexternal-await-in-boundaryif the line really does await something other than its own session.
Rules 1 and 5–8 need no configuration, but they still need files: with no roots configured the checker walks nothing, so it reports "nothing was checked" and exits 1 rather than printing a pass. A gate that scans zero files must not be green.
Rules 2–4 additionally need to know where your code lives and stay off until configured — reported as skipped, never as passed:
[tool.txbound.check]
scan_roots = ["myapp/services"] # rule 2: code that must declare boundaries
scan_files = ["myapp/tasks.py"] # single files to scan, same rules
entrypoint_roots = ["myapp/api"] # rules 1 & 4-8 additionally look here
model_patterns = ["myapp/**/models"] # rule 3: model directories or .py files (glob)
session_factories = ["SessionLocal"] # rule 4: what creates sessions
exempt_roots = ["myapp/migrations"] # suppression — applies to every rule
extra_write_methods = [] # rule 2: custom query-builder write methods
unresolved_allowlist = [] # receivers confirmed not to be DB sessions
Any single line can opt out of a single rule:
session.commit() # txbound: ignore[manual-session-control] — worker owns this session
The rule name is required — a bare # txbound: ignore is reported, not
honoured, because it would also silence rules added in later releases. A
trailing comment covers its own line and nothing else; a comment written on a
line of its own also covers the line below it, which is what makes a
multi-line statement suppressible.
An ignore that suppresses nothing is itself reported as unused-ignore — a
misspelled or unknown rule name, a name that can never be suppressed, or a
comment left behind after the code it once excused was fixed. When the named
rule never ran over that file — it is unconfigured, or the file sits outside
the roots it runs over — the report says so and tells you to keep the
comment: the line is unchecked because the rule is off, not because the
comment is dead.
--list prints one violation per line and exits 1 when violations exist,
so both the human format and the machine format fail a build. Pre-commit:
- repo: local
hooks:
- id: txbound-check
name: transaction boundary check
entry: txbound-check
language: system
pass_filenames: false
What this does not do
Knowing where a package stops is worth as much as knowing what it does.
- Two propagation modes, not five. REQUIRED and REQUIRES_NEW. There is no MANDATORY, SUPPORTS, NEVER or NOT_SUPPORTED, and no explicit NESTED: a joined inner boundary always runs inside a savepoint, and that is not something you can turn off.
- No retries. A deadlock or a serialization failure raises. Deciding whether an operation is safe to repeat is the caller's, and a decorator that guessed would repeat side effects that are not in the database. On MySQL and MariaDB that raise does not always surface where this sentence implies — see the InnoDB deadlock bullet below.
- No after-commit hook. Outbox rows and event publishing are not this package's business. The boundary ends at the commit.
- One database. No multi-database routing, no two-phase commit, no distributed transactions.
- The session is yours. The entrypoint creates it, owns its lifetime and closes it. This package decides only when the work is committed.
- Writes that bypass the session are invisible to the runtime guard. A raw
engine.connect()fires none of the ORM events the guard listens on. That ground belongs to the static checker — rule 5,raw-connection. - On MySQL and MariaDB, a deadlock inside a joined boundary loses the outer's
work silently. InnoDB rolls the whole transaction back on a deadlock and
destroys every savepoint with it, so the joined call's own
ROLLBACK TO SAVEPOINTis what fails — error 1305, SAVEPOINT … does not exist — and it supersedes the deadlock error before the outer ever sees it. The outer'scommit()then raises nothing at all, because there is no transaction left to commit, and the outer's own writes are gone too. A caller that swallows the inner's failure — the documented, supported thing to do — walks away believing its own write committed. No library can prevent this; the savepoint is gone before this package is handed control. Verified bytest_a_deadlock_in_a_joined_boundary_hollows_the_outer_commit. See Verified combinations.
Two other packages solve this problem differently:
sqlalchemy-transactional
and
transactional-sqlalchemy.
Read them and judge for yourself — describing someone else's behaviour here
would go stale the moment they release, and a stale claim about a neighbour is
worth less than nothing.
Requirements
- Python 3.11+
- SQLAlchemy 2.0+
- For
txbound.asyncio, theasyncioextra (pip install "transactional-boundary[asyncio]"), which adds SQLAlchemy's ownasyncioextra and thereforegreenlet, plus an async driver of your choice (asyncpg,aiosqlite, …). A sync-only stack needs none of it: nothing in the synchronous half importssqlalchemy.ext.asyncio.
Verified combinations
Every row below is a step in CI, and every step fails the build if its tests
skip — except SQLite's: those tests carry no skipif at all, so they cannot
skip in the first place; what CI's skip-guard actually watches behind that row
is the combination-matrix files that run on SQLite. Nothing is listed here on
the strength of "it should work".
| Engine | Driver | Sync | Async | Notes |
|---|---|---|---|---|
| SQLite 3 | built-in / aiosqlite |
✓ | ✓ | Needs BEGIN control taken from the driver — see SQLite. The savepoint contract passes here for the wrong reasons, so CI proves it on PostgreSQL |
| PostgreSQL 16 | psycopg2 |
✓ | — | |
| PostgreSQL 16 | psycopg (3) |
✓ | — | Synchronous only — the async contracts run on asyncpg, not on this driver |
| PostgreSQL 16 | asyncpg |
— | ✓ | |
| MySQL 8 | PyMySQL / aiomysql |
✓ | ✓ | Every boundary contract holds, with one exception: a deadlock inside a joined boundary loses the outer's writes silently — see What this does not do. lock_timeout warns; only PostgreSQL honours it |
| MariaDB 11 | PyMySQL / aiomysql |
✓ | ✓ | Same as MySQL |
- Python: 3.11, 3.12, 3.13, 3.14 — each in the matrix until its upstream end of life.
- SQLAlchemy: 2.x, floor
2.0. That floor is measured, not assumed — but only on Python 3.11, and only against PostgreSQL and MySQL: CI installs exactly2.0.0there and runs the whole suite against it. MariaDB shares MySQL's dialect and already got a full run at the newest SQLAlchemy earlier in the same job, so the floor step does not repeat it. A pre-release run also happens on every build, and never blocks one. Separately: SQLAlchemy below2.0.16emits aDeprecationWarningat import on Python 3.12 and newer (sqlalchemy.sql.sqltypes.Intervalevaluates the now-deprecateddatetime.utcfromtimestampat class-definition time) — harmless by itself, and fatal only if your own project turns warnings into errors. If yours does and you run Python 3.12+, use SQLAlchemy2.0.16or newer; everyone else can stay at the measured floor. This does not change the dependency declaration, which stayssqlalchemy>=2.0.
An engine that is not in this table is not unsupported so much as unmeasured — nothing here claims it fails. If you run one, an issue reporting what happened is the most useful thing you can send.
Stability
What SemVer covers here. The public API is what txbound and
txbound.asyncio export in __all__. Anything reached through an underscored
module (txbound._core, txbound._guard, …) is internal and may change in any
release.
The static checker's findings are not covered. A rule that learns to see
more will turn a green build red on unchanged code — that already happened
between 0.2.0 and 1.0.0, where four rules reported where they previously did
not. Rule changes ship in minor releases, and every one of them gets a callout
in the changelog naming what newly reports and why. If a new finding is wrong
on your code, # txbound: ignore[rule-name] is the escape, and an issue about
it is worth filing.
Deprecation. Anything on its way out raises a DeprecationWarning for at
least one full minor release before it is removed, and the changelog says what
replaces it.
Support window. Each Python version stays in the test matrix until its upstream end of life. SQLAlchemy 2.x is supported from the floor in Verified combinations upward.
Verifying what you installed. Releases are published from GitHub Actions
through PyPI's trusted publishing — no API token exists to leak — and the
artifacts carry a PEP 740 provenance attestation, verifiable with the
pypi-attestations tool
(pip install pypi-attestations):
python -m pypi_attestations verify pypi \
--repository https://github.com/nohchang/transactional-boundary \
<downloaded-file>
License
MIT
Metadata
Release files for transactional-boundary 1.0.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| transactional_boundary-1.0.1.tar.gz | 229.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| transactional_boundary-1.0.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 339.2 kB
Release files / transactional_boundary-1.0.1.tar.gz
| Download URL | transactional_boundary-1.0.1.tar.gz |
|---|---|
| Size | 229.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2986a870cf9bb63173005ca5a130d5078972383ab9f357fb61d8067dc0b9b3fe
|
|
BLAKE2b-256 checksum How to use checksums |
3b657599beb2acb2e8f42ff113da4d60437895594e08dae3769893816bc3f28b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 10, 2026.
Transparency logRelease files / transactional_boundary-1.0.1-py3-none-any.whl
| Download URL | transactional_boundary-1.0.1-py3-none-any.whl |
|---|---|
| Size | 109.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
9dfb6f46d970607685ba6bcffac39c699e832d0b67b6aef99067fdc1bcded9d4
|
|
BLAKE2b-256 checksum How to use checksums |
29064482b60c2fceec4b1ac5242f1cf87aa97497cfcc1a425820f8add199e9a3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 10, 2026.
Transparency log