Database Transactions: The Backend Safety Net Every Developer Should Understand
    Backend
    Database
    SQL
    ACID
    PostgreSQL
    MySQL
    Laravel

    Database Transactions: The Backend Safety Net Every Developer Should Understand

    Why a successful line of code does not always mean a successful operation, how ACID transactions protect multi-step writes like payment processing, and how to use them correctly in PostgreSQL, MySQL, Laravel, Node.js, and FastAPI.

    HOA

    Harrison Onyango Aloo

    Backend Software Engineer — Node.js · Python · Payment Integrations

    September 14, 2026
    15 min read

    Database Transactions: The Backend Safety Net Developers Should Understand

    1. Introduction

    Here's a bug that doesn't throw an error, doesn't crash the server, and doesn't show up in your logs as a failure: a payment comes in, the payment record gets created, the account balance gets updated... and then the server restarts, or a database connection drops, or an unrelated exception fires two lines later. The transaction record that was supposed to complete the operation never gets written.

    Nothing "failed." Your code ran. Two out of three writes succeeded. From the outside, everything looks fine — until someone reconciles the books and finds a balance that doesn't match any transaction history, or a support ticket comes in from a customer who paid but shows no record of it.

    This is the gap between "the code executed" and "the operation succeeded." A single business action — accepting a payment, placing an order, transferring money between accounts — usually isn't one database write. It's a sequence of writes that only makes sense together. If step 2 succeeds and step 3 fails, you don't have a partial success. You have corrupted data that will actively mislead every report, audit, and support agent that touches it afterward.

    A database transaction is the mechanism that closes this gap. It groups a set of operations into a single unit: either all of them are committed to the database, or none of them are. There is no in-between state visible to anyone else querying the database. This post walks through what transactions actually guarantee, where they break down in practice, and how to use them correctly across a few common stacks — PostgreSQL, MySQL, Laravel, Node.js, and FastAPI.

    2. The ACID Properties

    Transactions are usually explained through four guarantees, commonly remembered as ACID.

    Atomicity — the operations in a transaction are treated as one indivisible action. If any part fails, the database rolls back everything that happened so far in that transaction, as if none of it had ever run. There's no "the payment record was created but the balance wasn't updated" state that persists.

    Consistency — a transaction can only take the database from one valid state to another. Constraints, foreign keys, and rules you've defined (a balance can't go negative, an order can't reference a non-existent customer) are never left violated once the transaction commits. Atomicity protects the individual operation; consistency protects the rules of your data model.

    Isolation — concurrent transactions shouldn't see each other's half-finished work. If two requests are updating the same account balance at the same time, isolation determines what each one is allowed to see of the other's in-progress changes. This is the property with the most nuance in practice, and it's covered in detail in Section 6.

    Durability — once a transaction commits, the change survives. Even if the server crashes or loses power a millisecond later, a committed transaction's effects are on disk and will still be there after restart.

    None of these are abstract guarantees for a database course — they're the reason a payment system, a booking platform, or an inventory system doesn't quietly drift into a state where the numbers don't add up.

    3. A Real-World Example

    Consider a fairly ordinary payment flow — the kind you'd build for an M-Pesa integration, a card payment, or any commerce checkout:

    1. A payment notification arrives (webhook or callback).
    2. The system creates a payment record.
    3. The system updates the customer's account balance.
    4. The system creates a transaction record for the customer's history/statement.

    Each of these is a separate write. Now ask: what happens if step 3 succeeds but step 4 fails — maybe a validation exception, maybe a dropped connection, maybe the process gets killed by a deploy mid-request?

    Without a transaction wrapping all three writes, you're left with:

    • A payment that's marked as received.
    • An account balance that's already been credited.
    • No transaction record explaining why the balance changed.

    The customer's balance is technically correct at that moment, but there's no audit trail. A week later, when someone asks "why did this balance change on this date," there's nothing to point to. Worse, if the balance update logic runs again on retry (a common pattern for payment webhooks, which providers frequently resend), the account could get credited twice, because nothing recorded that the first attempt already applied the change.

    Wrapped in a transaction, this becomes safe: either the payment, the balance update, and the transaction record are all saved, or none of them are — and the caller gets a clear failure it can retry.

    4. What Can Go Wrong Without Transactions

    The payment example generalizes into a handful of concrete failure patterns that show up constantly in production systems that skip transactions:

    • Partial updates. Some writes in a multi-step operation succeed, others don't, and the database is left in a state that shouldn't be possible according to the business logic.
    • Inconsistent balances. Financial or quantity fields (account balances, stock counts, loyalty points) drift out of sync with the records that are supposed to explain them.
    • Duplicate or missing records. A retried request re-runs part of an operation because the system has no reliable record of what already completed, leading to double-charges, duplicate orders, or orphaned rows that reference nothing.
    • Corrupted state that's hard to detect. These bugs rarely throw exceptions. They surface later, as a support ticket, a failed reconciliation, or a number that "just doesn't look right" during an audit — by which point the root cause is difficult to trace back.

    None of these look like a "bug" in the traditional sense of broken code. They look like bad data, and by the time anyone notices, the incorrect state has often already propagated into invoices, reports, or downstream systems.

    5. Transactions in Practice

    The API differs by stack, but the underlying model — begin, do the work, commit or roll back — is the same everywhere.

    Raw SQL (PostgreSQL / MySQL)

    BEGIN;
    
    INSERT INTO payments (reference, amount, status) VALUES ('MP12345', 500, 'received');
    
    UPDATE accounts SET balance = balance + 500 WHERE id = 42;
    
    INSERT INTO transactions (account_id, amount, type) VALUES (42, 500, 'credit');
    
    COMMIT;
    

    If any statement between BEGIN and COMMIT fails, you issue a ROLLBACK instead, and every statement in that block is undone. In application code, this is almost always wrapped in a try/catch so a thrown exception triggers the rollback automatically rather than relying on you to remember it.

    For row-level safety when reading a value you're about to update (like a balance), both engines support SELECT ... FOR UPDATE, which locks the row so no other transaction can modify it until yours finishes:

    BEGIN;
    SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
    UPDATE accounts SET balance = balance + 500 WHERE id = 42;
    COMMIT;
    

    Laravel

    Laravel wraps the whole pattern in a single method. DB::transaction() takes a closure; if anything inside throws, Laravel rolls back automatically and rethrows the exception — you don't call commit() or rollBack() yourself:

    use Illuminate\Support\Facades\DB;
    
    DB::transaction(function () {
        $account = Account::where('id', 42)->lockForUpdate()->first();
    
        Payment::create([
            'reference' => 'MP12345',
            'amount' => 500,
            'status' => 'received',
        ]);
    
        $account->increment('balance', 500);
    
        Transaction::create([
            'account_id' => $account->id,
            'amount' => 500,
            'type' => 'credit',
        ]);
    });
    

    lockForUpdate() is Laravel's equivalent of SELECT ... FOR UPDATE — it prevents another request from reading and modifying the same row until this transaction completes. DB::transaction() also accepts a second argument for automatic retries on deadlock:

    DB::transaction(function () {
        // ...
    }, attempts: 5);
    

    If you need manual control — for example, deciding whether to commit based on logic that doesn't fit neatly in a closure — DB::beginTransaction(), DB::commit(), and DB::rollBack() are available directly.

    Node.js / Express (with Prisma)

    Prisma's interactive transactions work the same way: pass a callback, and any thrown error inside it triggers an automatic rollback.

    await prisma.$transaction(async (tx) => {
      await tx.payment.create({
        data: { reference: 'MP12345', amount: 500, status: 'received' },
      });
    
      await tx.account.update({
        where: { id: 42 },
        data: { balance: { increment: 500 } },
      });
    
      await tx.transaction.create({
        data: { accountId: 42, amount: 500, type: 'credit' },
      });
    });
    

    All three operations share the tx client, meaning they run inside one database transaction. If the third write fails — a unique constraint violation, a connection drop — the first two are rolled back as well, and Prisma throws the error back to your catch block.

    For any Node ORM (Knex, Sequelize, TypeORM, or raw pg/mysql2 clients), the shape is identical: acquire a transaction-scoped client, run every related write through it, commit on success, roll back on any exception.

    Python / FastAPI (with SQLAlchemy)

    With an async SQLAlchemy session, session.begin() gives you the same all-or-nothing block:

    async def process_payment(session: AsyncSession, reference: str, account_id: int, amount: int):
        async with session.begin():
            session.add(Payment(reference=reference, amount=amount, status="received"))
    
            account = await session.get(Account, account_id)
            account.balance += amount
    
            session.add(Transaction(account_id=account_id, amount=amount, type="credit"))
        # commits automatically on exit; rolls back if an exception was raised inside the block
    

    If your session is bound to the request lifecycle instead, the equivalent explicit pattern is a try/except around commit()/rollback():

    try:
        session.add(payment)
        account.balance += amount
        session.add(transaction_record)
        await session.commit()
    except Exception:
        await session.rollback()
        raise
    

    The specific syntax changes across stacks, but the contract is always: define the boundary of "this must all happen together," and let the database undo everything inside that boundary if any part fails.

    6. Transaction Isolation

    Atomicity handles what happens when one operation fails partway through. Isolation handles a different problem: what happens when two operations are running at the same time, touching the same data.

    Without isolation, concurrent transactions can interfere with each other in several well-defined ways:

    • Dirty reads — a transaction reads data written by another transaction that hasn't committed yet. If that other transaction rolls back, you've now made a decision based on data that never actually existed.
    • Non-repeatable reads — a transaction reads the same row twice and gets two different values, because another transaction committed a change in between.
    • Phantom reads — a transaction re-runs the same query and gets a different set of rows, because another transaction inserted or deleted rows matching the query's condition in between.

    The SQL standard defines four isolation levels — Read Uncommitted, Read Committed, Repeatable Read, and Serializable — as increasingly strict guarantees against these phenomena. PostgreSQL's default is Read Committed, and its Repeatable Read is actually stricter than the SQL standard requires (it prevents phantom reads too, via its MVCC snapshot model). MySQL's InnoDB engine defaults to Repeatable Read.

    Why does this matter for a payment or balance-update flow specifically? Picture two requests hitting "add 100 to account 42" at nearly the same instant. Both read the current balance as 500. Both compute 600. Both write 600. The correct result should have been 700 — one of the updates was silently lost. This is a classic lost update, and it's exactly why the Laravel and raw-SQL examples above use SELECT ... FOR UPDATE / lockForUpdate(): locking the row on read forces the second transaction to wait until the first one finishes, rather than working from stale data.

    The practical takeaway: isolation levels and locking aren't a topic you need to master before writing your first transaction, but any flow involving a balance, a counter, or a limited-quantity resource (seats, inventory, stock) needs to account for concurrent access explicitly — either through row locks or by choosing a stricter isolation level for that specific operation.

    7. Transactions Aren't a Magic Solution

    Wrapping code in DB::transaction() isn't the end of the story. A few things go wrong often enough to call out specifically.

    Keep transactions short. A transaction holds locks and, on most engines, a database connection for its entire duration. The longer it runs, the longer it blocks other operations and the more likely it collides with something else. A transaction that takes 50ms is a normal part of a request. A transaction that takes 5 seconds because it's looping over an API call or doing heavy computation is a bottleneck waiting to happen.

    Don't make external calls inside a transaction unless you have to. This is the mistake that causes the most damage in payment flows specifically: calling a third-party payment gateway, sending an SMS, or hitting any external API from inside an open transaction. That external call might take 800ms, might time out, might hang — and for its entire duration, your transaction is holding a database connection and any locks it's acquired. Under load, this drains your connection pool, and a slow third party effectively becomes a database outage. Do the external call before opening the transaction (and act on its confirmed result inside the transaction), or after committing the parts that don't depend on it.

    Handle rollback correctly, and design for retries. Most frameworks roll back automatically when an exception escapes the transaction block — but that only works if you're not swallowing exceptions inside the block. A try/catch that logs an error and continues inside the transaction closure can leave the transaction thinking everything succeeded when it didn't. Additionally, because a transaction function can legitimately run more than once (deadlock retries, at-least-once delivery from a message queue, a payment provider resending the same webhook), the operation inside it should be safe to repeat — this is where idempotency keys matter. A unique constraint on a payment reference, or a check for an already-processed webhook event ID, stops a retried request from creating a duplicate payment or double-crediting a balance.

    Understand deadlocks, and retry instead of panicking. A deadlock happens when two transactions each hold a lock the other needs — neither can proceed. Both MySQL's InnoDB and PostgreSQL detect this automatically and abort one of the transactions (the "victim"), letting the other continue. This is not a sign of data corruption; it's the database protecting you. The fix is almost always: retry the entire transaction (not just the failed statement), access shared rows in a consistent order across your codebase to reduce the chance of a circular wait, keep transactions short, and make sure the right indexes exist so the database isn't locking more rows than it needs to. Laravel's DB::transaction($callback, attempts: 5) bakes this retry loop in directly; in other stacks, a small retry wrapper with capped exponential backoff does the same job.

    One more pattern worth knowing if you're dealing with webhooks or any at-least-once delivery (which describes almost every payment callback): the transactional outbox pattern. If your operation needs to both update the database and trigger something outside it (publish an event, queue a notification), write a record of that "outbound" intent into an outbox table inside the same transaction as your other writes, and let a separate background process handle actually publishing it. This avoids the gap where your database commit succeeds but the follow-up action (that used to happen right after, outside the transaction) never fires because the process crashed in between.

    8. Practical Checklist

    Before shipping a feature that touches the database in more than one place, work through this:

    • Identify multi-step operations. Any time a single user-facing action results in more than one write, ask whether they need to succeed or fail as a unit.
    • Define what must succeed or fail together. Not everything needs to be in the same transaction — logging an analytics event probably shouldn't block or roll back a payment. Be explicit about which writes are core to correctness and which are best-effort.
    • Wrap the core writes in a transaction. Use your framework's transaction helper rather than manual beginTransaction/commit calls where possible — it removes an entire class of "forgot to roll back" bugs.
    • Keep external calls and slow work out of the transaction body. If you must call a third-party service as part of the flow, do it outside the transaction and record its result inside a short one.
    • Design for retries with idempotency. Assume any webhook, queued job, or payment callback can be delivered more than once, and make sure repeating it doesn't duplicate the effect.
    • Test failure scenarios, not just the happy path. Deliberately fail step 2 of 3 in a test and assert that step 1 was rolled back too. This is the test that actually proves your transaction boundary is doing its job — a passing happy-path test tells you nothing about what happens when things go wrong.

    9. Conclusion

    Good backend engineering isn't just about storing data — it's about keeping that data correct when things fail, because in production, things will fail: connections drop, processes crash mid-request, third parties time out, retries arrive twice. A system that only behaves correctly when nothing goes wrong isn't a correct system; it's a system that hasn't been tested by reality yet.

    Transactions are the tool that makes "correct even when something fails" a property you can rely on instead of something you hope for. They don't remove the need for good design — you still have to think about concurrency, retries, and where the transaction boundary should sit — but they turn "partial failure" from a silent, hard-to-detect category of bug into something the database actively prevents. That's a small amount of code for a guarantee that's very easy to take for granted, right up until the day it saves you from a very bad afternoon.


    Further reading

    HOA

    Harrison Onyango Aloo

    Backend Software Engineer — Node.js · Python · Payment Integrations

    Thanks for reading. I write about backend engineering — payments, APIs, and the failure modes that only show up in production. Find me here:

    HOA

    Harrison Aloo

    Software Engineer | Backend Developer | Open Source Enthusiast

    Connect

    © 2026 Harrison Onyango Aloo. All rights reserved.

    Chat on WhatsApp