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
public void signup(String email, String teamName) { // no @Transactional
User user = users.save(new User(email)); // each save() commits on its own
Team team = teams.save(new Team(teamName));
memberships.save(new Membership(user, team, "owner"));
}
public function signup(string $email, string $teamName): User
{
$user = User::create(['email' => $email]); // autocommit
$team = Team::create(['name' => $teamName]); // autocommit
$team->members()->attach($user, ['role' => 'owner']);
return $user;
}
async function signup(email: string, teamName: string) {
const user = await prisma.user.create({ data: { email } }); // autocommit
const team = await prisma.team.create({ data: { name: teamName } });
await prisma.membership.create({ data: { userId: user.id, teamId: team.id, role: "owner" } });
}
Reproduce it
This simulation commits each step immediately, then "crashes" between the second and third step:
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
@Transactional // public method on a Spring bean, called from another bean
public User signup(String email, String teamName) {
User user = users.save(new User(email));
Team team = teams.save(new Team(teamName));
memberships.save(new Membership(user, team, "owner"));
events.publishEvent(new UserSignedUp(user.getId())); // handled by @TransactionalEventListener(AFTER_COMMIT)
return user;
}
public function signup(string $email, string $teamName): User
{
$user = DB::transaction(function () use ($email, $teamName) {
$user = User::create(['email' => $email]);
$team = Team::create(['name' => $teamName]);
$team->members()->attach($user, ['role' => 'owner']);
return $user;
});
SendWelcomeEmail::dispatch($user)->afterCommit();
return $user;
}
const user = await prisma.$transaction(async (tx) => {
const user = await tx.user.create({ data: { email } });
const team = await tx.team.create({ data: { name: teamName } });
await tx.membership.create({ data: { userId: user.id, teamId: team.id, role: "owner" } });
return user;
});
await queue.add("welcome-email", { userId: user.id });
The same simulation with a transaction — the crash rolls everything back and the retry succeeds:
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 NULLconstraints 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.