Transactions, Locking & Concurrency in Code · Transactions in Application Code

Transaction Boundaries and Autocommit

Why a signup that writes three rows can leave half an account behind, and how to draw transaction boundaries in every major framework.

Scenario

Example app: a SaaS signup endpoint creates a user, a team and a membership row linking them. It runs ~50 signups a minute — and each signup also sends a welcome email.

What you see in production

Support tickets: "I can't log in — it says my email is already registered, but I never finished signing up." The database has users with no team and no membership. They all coincide with a deploy that restarted pods mid-request, and with a brief database failover.

The naive code

Every statement runs in autocommit mode — each INSERT is its own transaction and is committed immediately:

def signup(email, team_name):
    user = User(email=email)
    session.add(user); session.commit()        # committed!
    team = Team(name=team_name)
    session.add(team); session.commit()        # committed!
    session.add(Membership(user_id=user.id, team_id=team.id, role="owner"))
    session.commit()                           # crash before this = orphan user + team

Reproduce it

This simulation commits each step immediately, then "crashes" between the second and third step:

Autocommit: a crash leaves half a signup behindTry it yourself
committed = {"users": [], "teams": [], "memberships": []}

def insert(table, row):
    committed[table].append(row)           # autocommit: durable immediately
    print(f"COMMIT  insert into {table}: {row}")

def signup(email, team, crash_after_team=False):
    insert("users", email)
    insert("teams", team)
    if crash_after_team:
        raise RuntimeError("pod killed during deploy")
    insert("memberships", (email, team))

try:
    signup("[email protected]", "Analytical", crash_after_team=True)
except RuntimeError as e:
    print("CRASH  ", e)

orphans = [u for u in committed["users"] if u not in [m[0] for m in committed["memberships"]]]
print("orphan users:", orphans)
print("retry ->", "email already registered!" if "[email protected]" in committed["users"] else "ok")

Output

COMMIT  insert into users: [email protected]
COMMIT  insert into teams: Analytical
CRASH   pod killed during deploy
orphan users: ['[email protected]']
retry -> email already registered!

Root cause

A transaction is the unit of all-or-nothing. Without an explicit one, the driver or ORM commits after every statement, so a failure between statements leaves the database in a state no code path intended. Crashes, timeouts, deploys and failovers make "between statements" happen every day at scale.

The fix

Wrap the whole business operation in one transaction — at the service layer, not per repository call — and keep side effects such as email out of it:

def signup(email, team_name):
    with Session(engine) as session, session.begin():   # one transaction; commit on success, rollback on error
        user = User(email=email)
        team = Team(name=team_name)
        session.add_all([user, team])
        session.flush()                                  # get ids without committing
        session.add(Membership(user_id=user.id, team_id=team.id, role="owner"))
    send_welcome_email.delay(email)                      # only after commit

The same simulation with a transaction — the crash rolls everything back and the retry succeeds:

One transaction: all or nothingTry it yourself
committed = {"users": [], "teams": [], "memberships": []}

class Transaction:
    def __enter__(self):
        self.pending = []
        return self
    def insert(self, table, row):
        self.pending.append((table, row))
    def __exit__(self, exc_type, exc, tb):
        if exc_type:
            print(f"ROLLBACK ({len(self.pending)} pending inserts discarded)")
            return False
        for table, row in self.pending:
            committed[table].append(row)
        print(f"COMMIT  {len(self.pending)} inserts")

def signup(email, team, crash=False):
    with Transaction() as tx:
        tx.insert("users", email)
        tx.insert("teams", team)
        if crash:
            raise RuntimeError("pod killed during deploy")
        tx.insert("memberships", (email, team))

try:
    signup("[email protected]", "Analytical", crash=True)
except RuntimeError as e:
    print("CRASH  ", e)
print("after crash:", committed)
signup("[email protected]", "Analytical")
print("after retry:", committed)

Output

ROLLBACK (2 pending inserts discarded)
CRASH   pod killed during deploy
after crash: {'users': [], 'teams': [], 'memberships': []}
COMMIT  3 inserts
after retry: {'users': ['[email protected]'], 'teams': ['Analytical'], 'memberships': [('[email protected]', 'Analytical')]}

Trade-offs

  • Transactions should be short. One business operation, a handful of statements. Wrapping a whole HTTP request (Django ATOMIC_REQUESTS, open-session-in-view) is convenient but holds a connection and locks for the entire request — see the next lessons.
  • Side effects can't roll back. Emails, payments and queue messages need after-commit hooks or the outbox pattern.
  • Where the boundary goes is a design decision: the service method that represents one business action, not the repository.

Detect it in production

  • Integrity checks that find orphans: SELECT u.id FROM users u LEFT JOIN memberships m ON m.user_id = u.id WHERE m.user_id IS NULL; — run them in a nightly job and alert on non-zero.
  • Foreign keys and NOT NULL constraints turn many partial writes into errors instead of silent corruption.
  • In code review: any method that calls commit() more than once, or that performs several writes without a visible transaction boundary.

Key points

  • Autocommit makes every statement its own transaction; partial failures leave partial data.
  • Put one transaction around one business operation, at the service layer.
  • Send emails and messages only after commit.
  • Use constraints and integrity queries to catch what slips through.

Practice questions

A signup inserts a user, then a team, then a membership, each with its own commit. What happens if the process dies after the second commit?

Answer

The user and team rows stay committed while the membership is missing, leaving an orphaned account. A retry then fails on the unique email. Wrapping all three inserts in one transaction makes the failure roll everything back.

Why should the welcome email be sent after commit rather than inside the transaction?

Answer

If the transaction rolls back after the email was sent, the user receives a welcome for an account that doesn't exist. After-commit hooks (Django on_commit, Laravel afterCommit, Spring @TransactionalEventListener) or an outbox make side effects follow the data.

Where should the transaction boundary usually be: repository method, service method or HTTP request?

Answer

Usually the service method that represents one business operation. Repository-level transactions are too small (partial writes); request-wide transactions are too large (they hold connections and locks during unrelated work).

Open in the app to mark lessons complete, take notes and track your progress.