Either Everything or Nothing
ACID is good...

Engineering @ Bolt
"Half measures avail us nothing."
Alcoholics Anonymous, The Big Book
Imagine you are transferring money between two bank accounts. The operation has two steps, subtract from account A and add to account B. Now imagine the database crashes between step one and step two. The money left account A. It never arrived in account B. It is gone, and the database is sitting there, perfectly healthy after its restart, with a permanent record of an errored universe.
This is why transactions exist, techinally, database transactions.
A database transaction is a unit of work that either completes entirely or does not happen at all. No partial state. No half-finished operations. The database either does all of it, or, if something goes wrong, it undoes whatever it managed to do and leaves the data exactly as it was before you started. This guarantee is not optional or configurable. It is the fundamental promise that makes databases usable for anything that matters, unlike some folks I can mention.
The four properties that define this guarantee are called ACID. They are worth understanding individually because they protect against different kinds of failure, and systems that sacrifice one of them sacrifice it for a specific reason.
"Atomicity" is the all-or-nothing property. A transaction is an atom, it cannot be split. "tm" is the root for "tmesis", literally cut, that's why when they remove your appendix, they call it "appendectomy". This is "a" + "tm" building "atom", opposite of that. Either all of its operations succeed and the changes are committed, or any failure causes all of its operations to be rolled back. The money transfer either happens completely or it does not happen at all. Atomicity is what the crash scenario above violates, and it is what a transaction restores.
"Consistency" means the database moves from one valid state to another. Coming. from "com" and "sistere" meaning "stand together" across time. Every constraint, every foreign key, every check you defined on your schema, must hold before the transaction starts and after it commits. If your transaction would violate a constraint, the database rejects it entirely. This is not a new guarantee that transactions add, it is the combination of atomicity with the schema constraints you already defined.
"Isolation" means concurrent transactions do not interfere with each other. It comes from "insula" literally meaning "island", unaffected by other things. If transaction A and transaction B are running simultaneously, neither should see the other's uncommitted changes. Each transaction should behave as if it is the only thing running against the database. How strictly this is enforced is the isolation level, and this is where things get interesting.
Durability means that once a transaction is committed, it is committed. A server restart, a power failure, a disk hiccup, none of these can undo a committed transaction. The word comes from "durus" literally meaning "lasting" or "tough". The database achieves this by writing to a write-ahead log before applying changes to the actual data files. If the server crashes mid-write, the log lets it replay or undo the transaction on recovery.
You invoke all of this with three SQL statements that your application sends to the database over its connection. BEGIN opens a transaction, telling the database to start treating everything that follows as one unit of work. COMMIT closes it successfully, making every change permanent. ROLLBACK closes it by undoing everything since the BEGIN. These are not internal database signals or configuration flags. They are SQL statements, sent from your code to the database the same way a SELECT or an UPDATE is sent, sometimes directly or sometimes through an ORM. When your Go code calls db.Begin(), the driver sends a BEGIN statement down the wire. When it calls tx.Commit(), it sends COMMIT. The database receives these, manages the transaction state on its side, and enforces the ACID guarantees for everything that happens between them.
ORMs and query builders do this on your behalf, which is convenient until a transaction boundary silently ends before you intended it to, and then it matters to know exactly what is being sent and when.
Isolation levels are the practical tradeoff the real world forces on you. Fully serializable isolation, where every transaction runs as if it were the only one, is the safest and the slowest. Most production databases default to something weaker, and understanding the weaknesses tells you which class of bugs you are accepting.
An isolation level is a setting, not a command. You configure it per transaction, per session, or as a database-wide default. In Postgres, you can set it when opening a transaction with BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;, or change the session default with SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL SERIALIZABLE;, or change the database default with ALTER DATABASE mydb SET default_transaction_isolation TO 'read committed';. The database reads this setting and adjusts how aggressively it protects your transaction from interference by others. You are telling it which guarantee you need, and implicitly, which class of anomalies you are willing to accept in exchange for less locking overhead.
There are four standard levels, and they form a hierarchy where each one prevents a different class of problem.
"Read Uncommitted" lets a transaction see uncommitted changes from other transactions. This is called a dirty read. You read a value that another transaction is currently modifying and has not committed yet. That transaction then rolls back. You made a decision based on data that never permanently existed. Most databases do not even offer this level, and the ones that do, you should not use it. I am genuinely not sure why it exists in the standard. I suspect someone had to document every possibility, including the wrong ones, to close the specification.
"Read Committed" prevents dirty reads. You only see committed data. But you can still get a non-repeatable read, meaning if you read a row twice within the same transaction, another transaction might have committed a change between your two reads and you get different values. Postgres defaults to this level.
"Repeatable Read" prevents non-repeatable reads. If you read a row, it will have the same value if you read it again in the same transaction. But you can still get phantom reads, where a query for rows matching a condition might return different rows if another transaction inserts or deletes data between your two executions of that query.
"Serializable" prevents everything. No dirty reads, no non-repeatable reads, no phantom reads. It behaves as if transactions execute one at a time in serial order, even when they do not. Postgres implements this with Serializable Snapshot Isolation, which is genuinely impressive engineering. It is also the most expensive option, because the database has to track potential conflicts between concurrent transactions and roll back ones that would violate serializability.
The names are admirably literal, by the way. Nobody was being clever about it. Read Committed commits to reading only committed data. Repeatable Read makes reads repeatable. Whoever wrote the SQL standard that day was probably someone studying astrophysics.
The practical heuristic is to use Read Committed for most operations, Repeatable Read when you need to read data multiple times and act on it consistently within a transaction, and Serializable when correctness is absolutely non-negotiable and you have profiled the cost. Using Serializable everywhere is not wrong. It is just a choice to pay for correctness at every operation, including the ones where the weaker level would have been fine.
Open two database connections simultaneously and run a transaction in each. The classic race condition demonstration is the lost update problem. I tried to write all of it in a single code block, let's see if you get it.
-- connection A -- connection B
BEGIN; BEGIN;
SELECT balance FROM accounts SELECT balance FROM accounts
WHERE id = 1; WHERE id = 1;
-- reads 1000 -- also reads 1000
UPDATE accounts
SET balance = 900
WHERE id = 1;
COMMIT;
-- balance is now 900
UPDATE accounts
SET balance = balance - 100
WHERE id = 1;
COMMIT;
-- balance is still 900, not 800
Both transactions read 1000. Both performed a deduction. One deduction was lost. At Read Committed, this is possible. At Serializable, one of these transactions would fail with a serialization error and you would retry it. At Read Committed, it silently produces wrong data.
This is not a contrived edge case. Every concurrent update to a row that reads-then-writes based on the read value has this structure. Account balances, inventory counts, vote tallies, anything that increments or decrements based on the current value is vulnerable to this at weaker isolation levels.
The safe version at Read Committed is to use SELECT FOR UPDATE, which acquires a row-level lock (like a mutex) and forces the second transaction to wait for the first to commit before proceeding. The version of truth from the first commit becomes the base for the second operation.
Long-running transactions are the mistake that is hardest to see coming. Every open transaction is holding resources, row locks, snapshot information, write-ahead log space that cannot be vacuumed until the transaction closes. A transaction that takes 30 seconds is not 30 times worse than one that takes 1 second. It is holding locks for 30 seconds, during which every other transaction that needs those rows either waits or times out.
The discipline is simple, keep transactions short. Do all preparation outside the transaction. Execute the writes quickly. Commit. Do not hold a transaction open while waiting for a network call, a user response, or a lock in another system.
Let me tell you about a production incident. The company name shall not exist for the purposes of this story. The numbers may have been adjusted. The shape of the failure is real.
A payment flow had three steps. Debit account A, credit account B, record the transaction. Originally, all three lived inside one transaction. At some point in the service's maintenance history, a network call that triggered a messaging service webhook was added by a dumbass junior, between the debit and the credit. A reasonable change. But nobody noticed that the transaction boundary now committed the debit before the network call ran, and the credit was left in a separate operation afterward.
The network call failed one night. The error was caught and handled gracefully, the user's phone number was incorrect. The function returned and the credit code was never reached. The first transaction had already committed. The debit was permanent.
The system returned 200. It had no idea anything was wrong. Money had left one account and arrived nowhere. The discrepancy surfaced hours later as an alert that ruined someone's evening. It was manually fixed later.
The code fix was to move the BEGIN to before the debit and the COMMIT to after the credit. If anything in between fails, the entire thing rolls back. The database does this exactly and reliably. It had always been willing to. The problem was that the code had stopped asking it to.
The junior is fine. I was the junior.
Transaction boundaries do not maintain themselves. They drift as code grows, as new steps get inserted, as services evolve. Every so often you need to re-read the code and ask the question: is everything that should be atomic still inside the same BEGIN and COMMIT? If the answer is no, the your testers called customers will tell you.
When something looks like corrupted data, check whether a transaction boundary is missing or incorrect. When throughput is mysteriously low under concurrent load, check whether a transaction is holding locks longer than it needs to. When a bug is reproducible intermittently under concurrency but not in isolation, check whether two operations that should be atomic are not.
The database is trying very hard to protect your data. ACID is the guarantee it makes. Your job is to use transactions correctly enough that the guarantee can actually hold.



