9.14 ACID Properties
Table of Contents
Why ACID Properties Matter
When you build backend applications, your data must stay correct, even when:
- Many users write to the database at the same time
- Servers crash
- Network connections fail
- A request stops in the middle of its work
Relational databases like PostgreSQL rely on transactions and ACID properties to keep data safe and consistent in all these situations.
ACID is a set of guarantees that describe how transactional databases behave:
- Atomicity
- Consistency
- Isolation
- Durability
You do not usually configure ACID directly. Instead, you use transactions, and the database uses ACID rules to make your transactions safe.
In this chapter, you will learn what each letter means, with practical backend examples.
A for Atomicity
What Atomicity Means
Atomicity says: a transaction is all or nothing.
A transaction can contain many SQL statements. With atomicity, either:
- All statements succeed, and the database saves them, or
- Any statement fails, and the database discards all of them
Nothing is left half-applied.
Atomicity rule
A transaction is indivisible: it cannot be partially committed.
If any part of a transaction fails, the entire transaction must roll back.
Real-World Example: Money Transfer
Imagine a bank transfer between two accounts:
- Alice sends $50 to Bob
In SQL, this might be:
BEGIN;
UPDATE accounts
SET balance = balance - 50
WHERE id = 1; -- Alice
UPDATE accounts
SET balance = balance + 50
WHERE id = 2; -- Bob
COMMIT;Atomicity ensures that both updates happen together:
- If the first update succeeds but the second fails, the database must roll back the first one
- You must never end in a state where Alice lost $50 but Bob did not receive it
If something goes wrong between BEGIN and COMMIT, the whole transaction is undone with ROLLBACK:
BEGIN;
UPDATE accounts
SET balance = balance - 50
WHERE id = 1;
-- Something fails here, for example:
-- UPDATE accounts SET balance = balance + 50 WHERE id = 2;
-- raises an error
ROLLBACK; -- undo the change to Alice's balanceAtomicity is critical anywhere you need changes to move together:
- Creating an order and subtracting inventory
- Creating a user and inserting initial profile records
- Logging an action and updating a related counter
Grouping Operations into One Transaction
In your backend code, you usually do not write BEGIN and COMMIT by hand. Your database driver or ORM provides a transaction API.
Pseudo code example:
with db.transaction(): # start transaction
db.execute("UPDATE accounts SET balance = balance - 50 WHERE id = 1")
db.execute("UPDATE accounts SET balance = balance + 50 WHERE id = 2")
# leaving the 'with' block commits on success, or rolls back on error
If any execute call fails, the context manager rolls back automatically. Atomicity is then enforced by the database.
C for Consistency
What Consistency Means
Consistency says: a transaction must move the database from one valid state to another valid state.
A valid state is defined by:
- Data types
- Constraints
- Rules and business logic
Examples of constraints:
- A column
emailisUNIQUE agemust be>= 0- A foreign key must point to an existing row
- An
orders.total_pricemust equal the sum of its items
Consistency rule
Every committed transaction must preserve all defined constraints and rules.
The database must never commit a state that violates its own invariants.
Consistency has two parts:
- Database-level constraints that the database enforces automatically
- Application-level rules that only your backend code can enforce
Database-Level Consistency: Constraints
Examples of SQL constraints:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
age INT CHECK (age >= 0),
account_id INT REFERENCES accounts(id)
);If you try to insert invalid data, the database will reject it:
INSERT INTO users (email, age, account_id)
VALUES ('john@example.com', -5, 123); -- age < 0
-- ERROR: new row for relation "users" violates check constraint "users_age_check"Atomicity and consistency work together:
- The invalid statement fails
- The transaction that contained it rolls back
- The database stays in a consistent state
Application-Level Consistency: Business Rules
Not all rules can be expressed as SQL constraints. For example:
- A user cannot order more items than available stock
- A coupon can only be used 3 times
- A subscription must not overlap with another active subscription for the same user
These rules live in your backend code. You use checks before running SQL.
Example:
def create_order(user_id, product_id, quantity):
product = db.fetch_one("SELECT stock FROM products WHERE id = %s", (product_id,))
if product["stock"] < quantity:
raise ValueError("Not enough stock")
with db.transaction():
db.execute(
"INSERT INTO orders (user_id, product_id, quantity) VALUES (%s, %s, %s)",
(user_id, product_id, quantity),
)
db.execute(
"UPDATE products SET stock = stock - %s WHERE id = %s",
(quantity, product_id),
)Here, consistency depends on both:
- The application logic that checks the stock
- The database constraints that might also enforce extra rules, such as
stock >= 0
I for Isolation
What Isolation Means
Isolation says: transactions should not see each other's partial work.
When several transactions run at the same time, each one should behave as if it is the only one running, at least from its own point of view.
In practice, databases offer different isolation levels, which balance:
- Safety
- Performance
You will learn detailed isolation levels in a separate chapter. Here we focus on intuition and typical problems.
Isolation rule
Concurrent transactions must not interfere in a way that produces incorrect or inconsistent results.
Each transaction should see a controlled view of data, independent from other unfinished transactions.
Typical Concurrency Problems
When isolation is weak, you can get strange behaviors.
| Problem type | Description in simple words |
|---|---|
| Dirty read | A transaction reads data that another transaction has not committed yet |
| Non-repeatable read | A transaction reads the same row twice and sees different values |
| Phantom read | A transaction runs the same query twice and sees different sets of rows |
| Lost update | Two transactions update the same row, and one update overwrites the other |
Lost Update Example
Imagine two HTTP requests at the same time:
- User A opens a page to update their profile name
- User B also opens the same profile page
- Both change the name and click "Save" within a short time
Sequence:
- Transaction T1 (User A) reads:
name = 'John' - Transaction T2 (User B) reads:
name = 'John' - T1 updates:
name = 'John A' - T2 updates:
name = 'John B'
If nothing prevents it, the final value is John B. T1's update is lost.
With proper isolation and sometimes extra logic, you can detect or prevent this.
One common technique is optimistic locking:
ALTER TABLE users ADD COLUMN version INT NOT NULL DEFAULT 0;Then you update with a condition:
UPDATE users
SET name = 'John A', version = version + 1
WHERE id = 1 AND version = 3;
If another transaction already changed version, this update affects 0 rows. Your backend notices and retries or shows a conflict to the user.
Isolation Levels Overview
Most relational databases support these isolation levels:
| Isolation level | Typical problems allowed |
|---|---|
| Read Uncommitted | Dirty reads, non-repeatable reads, phantoms |
| Read Committed | No dirty reads, but non-repeatable and phantoms allowed |
| Repeatable Read | No dirty or non-repeatable reads, some phantoms may appear depending on DB |
| Serializable | Prevents all of the above, behaves as if transactions run one by one |
Isolating more strongly usually reduces concurrency or requires more internal work, such as locking or retrying transactions.
As a backend developer, you must:
- Know the default isolation level of your database
- Understand how it affects your application
- Increase or decrease isolation when needed
Example in PostgreSQL:
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
-- transactional queries here
COMMIT;D for Durability
What Durability Means
Durability says: once a transaction is committed, its changes must not be lost, even if:
- The database process crashes
- The operating system crashes
- The power goes off
When the database comes back up, committed data must still be there.
Durability rule
After a transaction commits, the database must guarantee
that its changes will persist, even in the face of crashes.
How Databases Implement Durability
Implementation details vary, but common techniques include:
- Write-ahead logging (WAL)
The database writes all changes to a log on disk before applying them to the main data files.
If a crash happens, it can replay the log. - Flushing to disk
OnCOMMIT, the database ensures that data is written to non-volatile storage. - Recovery procedures
On startup after a crash, the database reads the log and: - Reapplies committed transactions that were not fully written
- Removes partial changes from incomplete transactions
As a backend developer, you do not implement these. But you must be aware that:
- Durability can have a performance cost
- Databases sometimes allow you to choose less strict durability for speed, but then you accept the risk of losing some last writes on a crash
Durability and Acknowledging Requests
In backend development, durability affects when you tell the user "success".
Example:
- Your API receives a POST
/ordersrequest - You insert a new row into
orderstable - The transaction commits
- You return HTTP 201 Created
If the database says "transaction committed", you can assume the order is safely stored. Even if the server crashes right after sending the response, the order is still in the database.
If you ever use features that relax durability, you must be careful about when you send a "success" response, because some writes might still be only in memory and not safe on disk.
How ACID Properties Work Together
ACID in One Transfer Example
Return to the money transfer example and see how ACID works as a whole.
Transaction:
BEGIN;
UPDATE accounts
SET balance = balance - 50
WHERE id = 1; -- Alice
UPDATE accounts
SET balance = balance + 50
WHERE id = 2; -- Bob
COMMIT;ACID guarantees:
- Atomicity
Both balances are updated together, or none are. - Consistency
Constraints, such asCHECK (balance >= 0), stay valid after the transaction. If they would be broken, the transaction fails and rolls back. - Isolation
Another transfer that reads the balance of Alice or Bob does not see a half-finished state of this transaction. - Durability
AfterCOMMIT, the new balances survive crashes.
Without any one of these, your data could become corrupted or surprising:
- Without atomicity, only one account might change
- Without consistency, negative balances might appear
- Without isolation, concurrent transfers might interfere
- Without durability, transfers might simply disappear after you send "success"
ACID and Backend Code
In practice, you usually call:
- BEGIN / COMMIT / ROLLBACK implicitly via your ORM
- Constraints via migrations and schema definitions
- Isolation level via configuration or per-transaction settings
Example pattern in a backend service:
def transfer(db, from_id, to_id, amount):
with db.transaction(): # atomic, durable unit
# checks for consistency
from_account = db.fetch_one("SELECT balance FROM accounts WHERE id = %s FOR UPDATE", (from_id,))
if from_account["balance"] < amount:
raise ValueError("Insufficient funds")
# changes
db.execute(
"UPDATE accounts SET balance = balance - %s WHERE id = %s",
(amount, from_id),
)
db.execute(
"UPDATE accounts SET balance = balance + %s WHERE id = %s",
(amount, to_id),
)
# on exit, commit is called. If anything fails, rollback happens.In this example:
- The
transaction()context manager gives you atomicity and durability - The code and database constraints together give you consistency
- The
FOR UPDATEclause helps with isolation, by locking the row while you change it
Common Misunderstandings About ACID
Misunderstanding 1: ACID Means Everything Is Always Perfect
ACID does not mean:
- You cannot have bugs in your logic
- All integrity rules are enforced automatically
- You never lose data due to disk failure or misconfiguration
ACID only describes behavior inside the database, with its own constraints and configuration.
You still must:
- Write correct business logic
- Use proper backups
- Configure replication and hardware
Misunderstanding 2: ACID Is Only for Banks
Banking is a classic example, but ACID is useful in many areas:
- E-commerce orders and payments
- Inventory updates
- Reservation systems (tickets, rooms, seats)
- Any multi-step operation that must not be partially applied
Any time you think "these operations must happen together", you are thinking about atomic transactions and ACID properties.
Misunderstanding 3: NoSQL Databases Are Always Non-ACID
Some NoSQL databases offer ACID properties, at least at the document level. Others offer limited forms or tunable consistency.
Relational databases are traditionally the strongest in ACID guarantees, but modern systems vary. When choosing a database, always check:
- Does it support transactions?
- What does ACID mean in its documentation?
- At what scope (single row, single document, multiple tables)?
Summary
- ACID describes four key properties of reliable transactions: Atomicity, Consistency, Isolation, Durability.
- Atomicity ensures that a transaction is all or nothing.
- Consistency keeps the database valid by enforcing constraints and rules.
- Isolation controls how concurrent transactions interact so they do not corrupt each other.
- Durability guarantees that committed data survives crashes.
As a backend developer, you make use of ACID by:
- Designing proper transactions around groups of related operations
- Defining constraints in your database schema
- Being aware of isolation levels and concurrency issues
- Trusting the database for durable writes, while still creating backups and disaster recovery plans
Understanding ACID is a foundation for every other database topic you will encounter, from schema design to performance and scalability.
Views: 10
KAHIBARO