Transactions
The backend maps Django’s transaction API onto YDB’s interactive transactions.
What is supported
transaction.atomic()— the body runs in an interactive transaction. On a clean exit the transaction is committed; on an exception (ortransaction.set_rollback(True)) it is rolled back.Autocommit — outside an
atomic()block every statement is its own transaction (the default).The connection stays usable after a rolled-back transaction.
The isolation level is
SERIALIZABLEfor interactive transactions by default. It can be changed per connection with theisolation_levelOPTIONSsetting (see Configurations); note that the non-serializable modes are read-only and reject writes.
What is not supported
Savepoints. YDB has no savepoints, so nested
atomic()blocks are not independent: a nested block does not create a savepoint, and an exception caught inside a nested block marks the whole transaction for rollback — further queries then raiseTransactionManagementErroruntil the outer block ends. Let exceptions propagate to the outermostatomic()instead of catching them mid-transaction.Django
TestCase. It relies on savepoints to isolate each test, so it does not work here. UseTransactionTestCase(withdatabases = {"default"}) for database tests; withpytest-django, mark database tests@pytest.mark.django_db(transaction=True).DDL inside
atomic(). YDB cannot roll back schema changes, so running DDL inside anatomic()block raisesTransactionManagementError. Migrations are applied non-atomically for the same reason.
Row locking (select_for_update)
YDB has no pessimistic row locks and no SELECT ... FOR UPDATE (YQL rejects the
syntax), so QuerySet.select_for_update() is a no-op: it runs as a plain
SELECT and neither locks nor raises (has_select_for_update = False, like
SQLite). You do not lose serialization safety, though — YDB uses optimistic
concurrency, so a read-modify-write inside transaction.atomic() already takes
optimistic locks on the rows it reads, and a conflicting concurrent write makes
the commit fail with OperationalError (“Transaction locks invalidated”).
Re-run the block on conflict instead of locking up front — wrap it with the
retry helper:
from django.db import transaction
from ydb_backend.retry import retry_ydb_errors
@retry_ydb_errors(idempotent=True)
def reserve_stock(product_id):
with transaction.atomic():
product = Product.objects.get(pk=product_id) # optimistically locked
product.stock -= 1
product.save()
Retries and conflicts
YDB uses optimistic concurrency with SERIALIZABLE isolation, so transactions
that touch the same rows concurrently can conflict. The losing transaction is
aborted with a retryable error, surfaced as django.db.OperationalError
(“Transaction locks invalidated”).
Statements in autocommit (outside
atomic()) are retried automatically by the YDB driver on transient / retryable errors — a single statement is its own transaction and is safe to replay.atomic()blocks are not retried automatically. Neither the driver (it cannot replay a multi-statement interactive transaction) nor Django (which has no built-in transaction retry) retries them. Retrying is the application’s responsibility — use theydb_backend.retryhelper, which retries only YDB-retriable errors using the native SDK policy:
from django.db import transaction
from ydb_backend.retry import retry_ydb_errors
@retry_ydb_errors(idempotent=True) # the transaction re-reads before writing
def transfer():
with transaction.atomic():
... # reads and writes
See Retries for idempotency rules, tuning and the full API.