PostgreSQL ACID Properties
Introduction
When working with databases, especially in applications where data integrity is crucial (like financial systems, inventory management, or user accounts), you need a reliable way to handle operations. PostgreSQL, like other relational database management systems, implements a concept known as ACID properties to ensure reliability in its transaction processing.
ACID is an acronym that stands for Atomicity, Consistency, Isolation, and Durability. These four properties guarantee that database transactions are processed reliably, even in cases of system failures, concurrent access, and other challenges.
In this guide, we'll explore each ACID property in PostgreSQL, understand why they matter, and see how they work in practice with code examples.
What is a Transaction?
Before diving into ACID properties, let's clarify what a transaction is:
A transaction is a sequence of database operations that are treated as a single logical unit of work. Either all operations in the transaction are completed successfully (committed), or none of them take effect (rolled back).
In PostgreSQL, transactions are created using the following syntax:
BEGIN;
-- SQL operations here
COMMIT; -- or ROLLBACK; to undo changes
Now, let's explore each ACID property and how PostgreSQL implements them.
Atomicity
What is Atomicity?
Atomicity guarantees that each transaction is treated as a single "atomic" unit, which either succeeds completely or fails completely. There are no partial transactions.
Why it Matters
Without atomicity, a failure during a transaction could leave your database in an inconsistent state. For example, if you're transferring money between two accounts and the system fails after debiting one account but before crediting the other, money would effectively disappear.
PostgreSQL Implementation
PostgreSQL implements atomicity through its transaction mechanism and Write-Ahead Logging (WAL) system.
Practical Example
Let's look at a bank transfer scenario:
BEGIN;
-- Deduct $100 from Account A
UPDATE accounts SET balance = balance - 100 WHERE account_id = 'A';
-- Add $100 to Account B
UPDATE accounts SET balance = balance + 100 WHERE account_id = 'B';
-- If everything looks good
COMMIT;
-- Or if there's a problem
-- ROLLBACK;
If any error occurs between the beginning and commit, you can use ROLLBACK
to undo all changes, maintaining atomicity.
Let's see what happens if we try to transfer more money than available and have a constraint:
BEGIN;
-- This account has a balance of $50
UPDATE accounts SET balance = balance - 100 WHERE account_id = 'A';
-- This will fail if we have a CHECK constraint ensuring balance >= 0
-- ERROR: new row for relation "accounts" violates check constraint "balance_must_be_positive"
ROLLBACK; -- The entire transaction is undone
Consistency
What is Consistency?
Consistency ensures that a transaction brings the database from one valid state to another valid state, maintaining all predefined rules like constraints, cascades, and triggers.
Why it Matters
Consistency protects the integrity of your data by ensuring that all database rules are enforced and no invalid data can be stored.
PostgreSQL Implementation
PostgreSQL enforces consistency through:
- Constraints (CHECK, UNIQUE, NOT NULL, FOREIGN KEY)
- Triggers
- Stored procedures
Practical Example
Let's see consistency in action with constraints:
-- First, let's create a table with constraints
CREATE TABLE accounts (
account_id VARCHAR(10) PRIMARY KEY,
holder_name VARCHAR(100) NOT NULL,
balance DECIMAL(10,2) CHECK (balance >= 0),
account_type VARCHAR(20) CHECK (account_type IN ('checking', 'savings'))
);
-- Now let's try a transaction that would violate a constraint
BEGIN;
INSERT INTO accounts
VALUES ('ACC123', 'John Doe', 1000.00, 'checking');
-- This violates the CHECK constraint on account_type
INSERT INTO accounts
VALUES ('ACC124', 'Jane Smith', 500.00, 'business');
-- Will result in: ERROR: new row for relation "accounts" violates check constraint "accounts_account_type_check"
COMMIT;
The entire transaction fails, and no changes are committed, preserving consistency.
Isolation
What is Isolation?
Isolation ensures that concurrent transactions execute as if they were running one at a time (serially), even though they may actually run in parallel for better performance.
Why it Matters
Without proper isolation, concurrent transactions can interfere with each other, leading to problems like:
- Dirty reads: Reading uncommitted data from another transaction
- Non-repeatable reads: Getting different results when reading the same data twice
- Phantom reads: When newly inserted rows appear in a transaction's result set upon requery
PostgreSQL Isolation Levels
PostgreSQL provides four isolation levels:
- READ UNCOMMITTED (behaves like READ COMMITTED in PostgreSQL)
- READ COMMITTED (default)
- REPEATABLE READ
- SERIALIZABLE (strongest isolation)
You can set the isolation level with:
BEGIN TRANSACTION ISOLATION LEVEL [LEVEL_NAME];
Practical Example
Let's demonstrate isolation with two concurrent transactions:
Transaction 1:
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- Initial check of account balance
SELECT balance FROM accounts WHERE account_id = 'A';
-- Output: 1000.00
-- Wait 5 seconds to simulate processing time...
-- Check again to make decision
SELECT balance FROM accounts WHERE account_id = 'A';
-- Output might now be 900.00 if Transaction 2 has run and committed!
COMMIT;
Transaction 2 (runs concurrently):
BEGIN;
-- Update account balance
UPDATE accounts SET balance = balance - 100 WHERE account_id = 'A';
COMMIT;
With the default READ COMMITTED
isolation, Transaction 1 sees the changes from Transaction 2 after they're committed.
If we want to prevent this and ensure consistent reads throughout the transaction, we could use:
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- Initial check of account balance
SELECT balance FROM accounts WHERE account_id = 'A';
-- Output: 1000.00
-- Even if Transaction 2 commits during this time, the next query will still show 1000.00
SELECT balance FROM accounts WHERE account_id = 'A';
-- Output: 1000.00
COMMIT;
Durability
What is Durability?
Durability guarantees that once a transaction is committed, it remains committed even in the case of system failure (power outage, crash, etc.). Committed data is stored permanently.
Why it Matters
Without durability, you could lose critical data during system failures, leading to inconsistent states and data loss.
PostgreSQL Implementation
PostgreSQL ensures durability through:
- Write-Ahead Logging (WAL): Changes are written to a log before they're written to the actual database files
- Checkpoints: Regular flushing of data to permanent storage
- Configurable synchronous commit levels
Practical Example
The durability property is handled internally by PostgreSQL, but you can configure how strict it should be with the synchronous_commit
parameter:
-- Maximum durability (default)
SET synchronous_commit = on;
-- Maximum performance, slight risk of data loss on crash
SET synchronous_commit = off;
With the default setting, you can be confident that after seeing a successful COMMIT message, your data is safely stored, even if the server crashes immediately after.
ACID Properties in Action: A Complete Example
Let's tie everything together with a more complete example showing a banking application with multiple accounts and transfers:
-- Create our banking schema
CREATE TABLE accounts (
account_id VARCHAR(10) PRIMARY KEY,
holder_name VARCHAR(100) NOT NULL,
balance DECIMAL(10,2) CHECK (balance >= 0),
account_type VARCHAR(20) CHECK (account_type IN ('checking', 'savings'))
);
CREATE TABLE transactions (
tx_id SERIAL PRIMARY KEY,
from_account VARCHAR(10) REFERENCES accounts(account_id),
to_account VARCHAR(10) REFERENCES accounts(account_id),
amount DECIMAL(10,2) CHECK (amount > 0),
tx_timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Insert some test data
INSERT INTO accounts VALUES
('A001', 'Alice Anderson', 1000.00, 'checking'),
('B002', 'Bob Brown', 500.00, 'savings');
-- Function to transfer money with ACID guarantees
CREATE OR REPLACE FUNCTION transfer_money(
sender VARCHAR(10),
recipient VARCHAR(10),
transfer_amount DECIMAL(10,2)
) RETURNS BOOLEAN AS $$
DECLARE
sender_balance DECIMAL(10,2);
BEGIN
-- Start transaction
BEGIN;
-- Check sender balance
SELECT balance INTO sender_balance FROM accounts
WHERE account_id = sender FOR UPDATE;
IF sender_balance < transfer_amount THEN
ROLLBACK;
RAISE NOTICE 'Insufficient funds';
RETURN FALSE;
END IF;
-- Update sender account
UPDATE accounts
SET balance = balance - transfer_amount
WHERE account_id = sender;
-- Update recipient account
UPDATE accounts
SET balance = balance + transfer_amount
WHERE account_id = recipient;
-- Record the transaction
INSERT INTO transactions (from_account, to_account, amount)
VALUES (sender, recipient, transfer_amount);
-- Commit the transaction
COMMIT;
RETURN TRUE;
END;
$$ LANGUAGE plpgsql;
-- Now let's use it
SELECT transfer_money('A001', 'B002', 200.00);
-- Expected output: true
-- Verify the results
SELECT * FROM accounts;
-- A001 should now have 800.00
-- B002 should now have 700.00
SELECT * FROM transactions;
-- Should show the recorded transaction
In this complete example:
- Atomicity: The entire transfer process (debit, credit, and recording) happens as one unit
- Consistency: The CHECK constraints ensure no negative balances
- Isolation: The
FOR UPDATE
lock prevents concurrent modifications to the same account - Durability: Once committed, the transfer remains in the database permanently
Visualizing ACID Properties
Let's visualize how ACID properties protect your data:
Summary
PostgreSQL's ACID properties provide a solid foundation for reliable data management:
- Atomicity: Transactions are all-or-nothing, preventing partial operations
- Consistency: Database remains in a valid state, following all defined rules
- Isolation: Concurrent transactions don't interfere with each other
- Durability: Committed data is permanently stored, even after system failures
These properties make PostgreSQL a robust choice for applications where data integrity is critical, such as financial systems, inventory management, and user databases.
Practice Exercises
- Create a banking system with accounts, customers, and transactions tables with appropriate constraints.
- Write a transaction that transfers money between accounts and ensures all ACID properties are maintained.
- Experiment with different isolation levels and observe their effects on concurrent transactions.
- Create a trigger that maintains referential integrity between tables.
- Write a function that performs a complex operation atomically.
Additional Resources
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)