Atomic writes with the transaction context manager, why the savepoint and manual begin/commit APIs should not be used, and model factories for generating test data.
Transactions & Factories
Section titled “Transactions & Factories”Two features that share a page because they are both about controlling writes: transactions decide whether a group of writes happens at all, and factories generate the writes your tests need.
Transactions
Section titled “Transactions”A transaction makes several statements atomic — either every write lands or none does. Any operation that touches more than one row and would leave the database inconsistent if interrupted halfway belongs in one.
from sillo.record import transaction
async with transaction(): user = await User.create(email="a@b.com") await Profile.create(user=user, bio="Hello") await Order.create(user=user, total=99.99)All three rows commit together on clean exit. If anything raises, the whole block rolls back and the exception propagates:
from tortoise.expressions import F
try: async with transaction(): await Account.filter(id=1).update(balance=F("balance") - 100) await Account.filter(id=2).update(balance=F("balance") + 100) raise RuntimeError("boom")except RuntimeError: pass
# Neither update survives.That is the whole contract, and it works. transaction() delegates to
Tortoise’s connection._in_transaction(), which handles the driver-level
protocol for each backend.
When to reach for this
Section titled “When to reach for this”Wrap a transaction around any write that must be all-or-nothing: a purchase that debits one account and credits another, a signup that creates a user plus a profile plus an audit row, a bulk import where a partial result is worse than no result.
Do not wrap read-only work in a transaction. It holds a connection and, on some isolation levels, takes locks, for no benefit.
Do not wrap slow external calls in one. A transaction that stays open while an HTTP request to a payment provider completes holds locks for the duration of that request, and a provider timeout becomes a database incident. Do the external call first, then open a short transaction to record the result.
Multiple connections
Section titled “Multiple connections”Pass connection_name to run against a non-default connection:
async with transaction(connection_name="analytics"): await Event.create(name="signup")A transaction covers one connection. Writes to two different databases
inside one async with are not atomic across both — that requires
two-phase commit, which neither Tortoise nor sillo implements. If you
need cross-database consistency, use an outbox table in the primary
database and a worker that replays it.
Nesting
Section titled “Nesting”transaction() blocks nest as far as Tortoise supports them, and
Tortoise’s _in_transaction() reuses the existing transaction when one is
already open on that connection rather than starting a second. The
practical implication is that an inner block committing does not make
its writes durable — the outer block still decides. Do not write code that
depends on an inner transaction being independently committed.
Savepoints are broken
Section titled “Savepoints are broken”Do not use begin / commit / rollback
Section titled “Do not use begin / commit / rollback”Logging
Section titled “Logging”transaction() catches, logs with logger.exception("Transaction rolled back"),
and re-raises. Every rolled-back transaction therefore produces a full
traceback in the sillo.record.transactions logger and whatever your
application logs when it handles the exception. Expect duplicate
tracebacks; if rollbacks are an expected part of your flow — optimistic
concurrency retries, for instance — raise the level of that logger to
avoid drowning your log aggregator.
A worked example
Section titled “A worked example”An idempotent order placement that keeps the external call outside the transaction.
@app.post("/orders")async def place_order(request, response): payload = request.validated_data
# Outside the transaction: slow, and safe to retry. charge = await payments.charge(payload.token, payload.amount_cents)
async with transaction(): order = await Order.create( user_id=request.user.id, amount_cents=payload.amount_cents, charge_id=charge.id, ) await OrderLine.bulk_create( [{"order_id": order.id, **line} for line in payload.lines] ) await AuditEntry.create(action="order.created", subject_id=order.id)
return response.json(order.to_dict(), status_code=201)If the database write fails after a successful charge, the transaction
rolls back and you are left with a charge and no order — which is why
charge_id is stored and why a reconciliation job matching charges
against orders is not optional in this design.
Factories
Section titled “Factories”A factory generates model instances with sensible defaults so a test can say “give me a user” without restating ten required fields.
from uuid import uuid4from sillo.record import Factory
class UserFactory(Factory): model = User definition = staticmethod(lambda: { "email": f"user-{uuid4().hex[:8]}@test.com", "name": "Test User", "plan": "free", })user = await UserFactory.create() # savedadmin = await UserFactory.create({"name": "Admin"}) # saved, with overridesusers = await UserFactory.create_many(5) # five saved rowsdraft = UserFactory.make() # not saved; draft.id is Nonemake() builds the instance without touching the database, which is what
you want for a unit test of serialization or validation logic. create()
calls instance.save(), so it goes through the normal write path —
including ValidatesBeforeSaveMixin and any casts.
definition must not be a normal method
Section titled “definition must not be a normal method”Uniqueness and create_many
Section titled “Uniqueness and create_many”create_many(count, overrides) applies the same overrides dict to
every instance. That is fine for shared attributes and fatal for unique
ones:
await UserFactory.create_many(5, {"email": "same@test.com"})# IntegrityError on the second rowGenerate unique values inside definition — a uuid4() fragment or a
counter — so that every call produces a distinct row. If you need
per-instance overrides, loop:
users = [await UserFactory.create({"email": f"u{i}@test.com"}) for i in range(5)]create_many also inserts one row at a time, which is fine for a handful
of test rows and slow for thousands. For bulk fixtures use
Model.bulk_create with a list comprehension over Factory.make().
state() returns a function, not a chainable factory
Section titled “state() returns a function, not a chainable factory”Factory.state(**kwargs) returns a plain callable that produces
{**cls.definition(), **kwargs}. There is no .state(...).create()
chain — the returned function has to be installed as a definition:
modifier = UserFactory.state(plan="enterprise")modifier() # {'email': '...', 'name': 'Test User', 'plan': 'enterprise'}A subclass is clearer than trying to make state() into something it is
not:
class AdminUserFactory(UserFactory): definition = staticmethod(lambda: { **UserFactory.definition(), "plan": "enterprise", "is_admin": True, })
admin = await AdminUserFactory.create()FactoryBuilder
Section titled “FactoryBuilder”A dictionary with a nicer error message. register(name, factory) stores
a factory class, get(name) retrieves it and raises
KeyError: Factory 'x' not registered when absent.
builder = FactoryBuilder()builder.register("user", UserFactory)builder.register("post", PostFactory)
user = await builder.get("user").create()It is worth using when fixtures are looked up by string — a seed-from-YAML script, say. For ordinary tests, importing the factory class is simpler.
Factories in tests
Section titled “Factories in tests”Use a transaction per test and roll it back, so tests do not have to clean up after themselves:
import pytestfrom tortoise import Tortoise, connections
@pytest.fixture(scope="session", autouse=True)async def db(): await Tortoise.init(db_url="sqlite://:memory:", modules={"models": ["myapp.models"]}) await Tortoise.generate_schemas() yield await connections.close_all()
@pytest.fixtureasync def user(): return await UserFactory.create()An in-memory SQLite database gives every session a clean schema in
milliseconds. If your application depends on Postgres-specific behaviour
— ON CONFLICT targets, array columns, ILIKE — test against Postgres
instead; SQLite will pass tests that production fails.
What not to do
Section titled “What not to do”Do not use TransactionContext.savepoint(). It raises TypeError
before doing anything.
Do not use begin() / commit() / rollback(). They bypass
Tortoise’s transaction tracking and break under connection pooling.
Do not make external calls inside a transaction. Locks are held for the duration.
Do not assume an inner transaction() commits independently. The
outermost block decides.
Do not expect atomicity across two connections. There is no two-phase commit.
Do not declare definition as def definition(self). Use a
staticmethod or a lambda.
Do not pass a unique value in create_many overrides. Every row gets
the same one.
Do not use create_many for thousands of rows. It is one insert per
row.
Performance notes
Section titled “Performance notes”A transaction holds a connection for its entire lifetime. With a pool of five, six concurrent long transactions starve every other request. Keep them short and measure the slowest statement inside them.
Row locks taken inside a transaction are held until commit. Two transactions updating the same rows in different orders deadlock; the database picks a victim and raises. Always touch rows in a consistent order — sorting ids before a bulk update is the usual fix.
create_many(1000) issues a thousand INSERT statements plus whatever
each save() triggers. Model.bulk_create([...]) issues roughly
1000 / batch_size. For test fixtures the difference is seconds versus
minutes.
API reference
Section titled “API reference”| Name | Signature | Notes |
|---|---|---|
transaction | async with transaction(connection_name="default") | Commits on exit, rolls back on exception |
TransactionContext.savepoint | async with tx.savepoint() | Raises TypeError; do not use |
begin / commit / rollback | (connection_name="default") | Unsafe with pooling; do not use |
Factory.make | (overrides=None) -> instance | Not saved |
Factory.create | (overrides=None) -> instance | Saved via save() |
Factory.create_many | (count, overrides=None) -> list | Same overrides for every row |
Factory.state | (**kwargs) -> Callable | Returns a function, not a factory |
FactoryBuilder | .register(name, factory), .get(name) | get raises KeyError |
Related
Section titled “Related”- Record Overview — setup and connection lifecycle
- Models & Mixins —
bulk_create,upsert, and validation hooks - Scopes & Events — why lifecycle events do not fire inside
create() - Migrations & Seeding — seeders and fixtures for non-test data
- Exception Handlers & Pydantic — turning
IntegrityErrorinto a 409 - Concurrency — how sillo schedules the tasks these run in