Skip to main content

transactional-boundary

PyPI Python CI License

한국어

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 and ServiceB.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:

  1. Every write needs a declared boundary. A flush, Core DML, textual DML, or bulk_* call outside @transactional / @independent_transaction raises BoundaryError, and the rejected pending state is rolled away — it will not be committed later by an unrelated boundary.
  2. Only the outermost boundary commits. An inner @transactional joins the outer one (REQUIRED propagation) and runs inside a savepoint; see Composing services.
  3. 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. A session.add() that never flushed is caught at the end of the boundary instead of silently vanishing on the rollback.
  4. 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.
  5. 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.
  6. @independent_transaction requires a separate session on a separate connection. A scoped_session hands back the caller's own session; an engine using SingletonThreadPool or StaticPool (which is what sqlite:// gives you) hands back the caller's own connection. Either one silently defeats REQUIRES_NEW, so both raise ConfigurationError rather than committing the caller's work by accident — the scoped registry at configure(), before anything can run on it, and the shared pool on the first call.
  7. Each world drives its own session type, loudly. The synchronous pair drives a Session, txbound.asyncio's drives an AsyncSession, 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 an async def under 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 RELEASE loses 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 Core insert() the outer has already executed. A query is not a write — an outer that ran a SELECT and then delegated is still refused, because a SELECT starts 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:

  1. The identity map differs — an ORM object loaded outside must not be passed in. Pass primitive ids.
  2. The outer transaction's uncommitted rows are invisible in here.
  3. 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 for update/delete) and a literal execute(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_transaction taking an ORM parameter is now reported where from txbound import independent_transaction as sync_it used to hide it.
  • rule 7 exempts a call to a boundary declared anywhere in the scanned tree, so composed-service calls and @independent_transaction calls stop being reported as external round trips. The exemption is harvested from write boundaries only, so await 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(...) without await builds 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 the AsyncDbSession above: 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 own class, def or assignment always wins), and a dotted reference (import app.deps, then db: app.deps.AsyncDbSession). The result must also be discarded inside an async 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 synchronous Session's flush()/delete() return None and have already run, so flagging those would be noise. The rule fails toward silence, so a clean run is not proof that every forgotten await is gone.

Rule 7 — renamed from async-boundary. Before 1.0.0 it flagged any boundary on an async 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 as unused-ignore and exits 1 — on its own, with no other violation in the file. Delete it if it was excusing a boundary on an async def; rename it to external-await-in-boundary if 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 SAVEPOINT is what fails — error 1305, SAVEPOINT … does not exist — and it supersedes the deadlock error before the outer ever sees it. The outer's commit() 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 by test_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, the asyncio extra (pip install "transactional-boundary[asyncio]"), which adds SQLAlchemy's own asyncio extra and therefore greenlet, plus an async driver of your choice (asyncpg, aiosqlite, …). A sync-only stack needs none of it: nothing in the synchronous half imports sqlalchemy.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 exactly 2.0.0 there 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 below 2.0.16 emits a DeprecationWarning at import on Python 3.12 and newer (sqlalchemy.sql.sqltypes.Interval evaluates the now-deprecated datetime.utcfromtimestamp at 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 SQLAlchemy 2.0.16 or newer; everyone else can stay at the measured floor. This does not change the dependency declaration, which stays sqlalchemy>=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)

Source distribution for transactional-boundary 1.0.1
File Size Uploaded
transactional_boundary-1.0.1.tar.gz 229.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for transactional-boundary 1.0.1
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

1.0.1 This release

2 release files

1.0.0

2 release files

0.2.0

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page