ACID Properties & Data Integrity Fundamentals
A beginner-friendly guide to keeping your data safe, consistent, and reliable.
Table of Contents
- What is Data Integrity?
- Database Constraints
- What is ACID?
- Atomicity - All or Nothing
- Consistency - Following the Rules
- Isolation - No Interference
- Durability - Data Survives
- Quick Reference
1. What is Data Integrity?
Data integrity means your data is accurate, consistent, and reliable. Think of it as the database's safety rules.
Why It Matters
Without data integrity:
- 😱 Customer emails are duplicated
- 💸 Bank account balance goes negative
- 🔗 Orders reference non-existent customers
- 📊 Data becomes unreliable
With data integrity:
- ✅ Each email is unique
- ✅ Balance can't be negative
- ✅ Orders always have valid customers
- ✅ Data you can trust
Two Ways to Enforce Integrity
- Database constraints - Rules enforced by the database itself (safer!)
- Application logic - Rules enforced by your code (less reliable)
Best practice: Use database constraints whenever possible. They're your last line of defense.
2. Database Constraints
Constraints are rules that prevent bad data from entering your database.
Common Constraints
exports.up = function(knex) {
return knex.schema.createTable('users', (table) => {
// PRIMARY KEY - Unique identifier
table.increments('id').primary();
// UNIQUE - No duplicates allowed
table.string('email').notNullable().unique();
// NOT NULL - Must have a value
table.string('username').notNullable();
// DEFAULT - Automatic value if not provided
table.decimal('balance', 10, 2).defaultTo(0);
// CHECK - Custom validation rule
table.check('balance >= 0'); // Balance can't be negative
table.timestamps(true, true);
});
};
Foreign Key Constraints
Foreign keys ensure relationships are valid:
exports.up = function(knex) {
return knex.schema
.createTable('customers', (table) => {
table.increments('customer_id').primary();
table.string('name').notNullable();
table.string('email').notNullable().unique();
})
.createTable('orders', (table) => {
table.increments('order_id').primary();
table.integer('customer_id').unsigned().notNullable();
table.decimal('total_amount', 10, 2).notNullable();
// Foreign key constraint
table.foreign('customer_id')
.references('customer_id')
.inTable('customers')
.onDelete('RESTRICT'); // Can't delete customer with orders
});
};
Handling Constraint Violations
async function createUser(email, username) {
try {
const [userId] = await knex('users').insert({
email: email,
username: username,
balance: 0
});
return userId;
} catch (error) {
// Handle specific errors
if (error.code === 'ER_DUP_ENTRY') {
throw new Error('Email already exists');
}
if (error.code === 'ER_CHECK_CONSTRAINT_VIOLATED') {
throw new Error('Balance cannot be negative');
}
throw error;
}
}
// Usage
try {
await createUser('john@example.com', 'john');
await createUser('john@example.com', 'john2'); // Fails - duplicate email
} catch (error) {
console.error(error.message); // "Email already exists"
}
3. What is ACID?
ACID is a set of properties that guarantee database transactions are processed reliably.
Atomicity - All or nothing
Consistency - Rules are followed
Isolation - Transactions don't interfere
Durability - Changes are permanent
These work together to keep your data safe, especially when multiple things happen at once.
4. Atomicity - All or Nothing
Atomicity means a transaction either completes fully or doesn't happen at all. No partial updates!
The Problem Without Atomicity
// ❌ DANGEROUS: No transaction
async function transferMoneyBad(fromId, toId, amount) {
// Step 1: Deduct from sender
await knex('users').where('id', fromId).decrement('balance', amount);
// 💥 App crashes here!
// Step 2: Add to receiver (never happens!)
await knex('users').where('id', toId).increment('balance', amount);
}
// Result: Money disappears! Sender loses $100, receiver gets nothing. 😱
The Solution With Atomicity
// ✅ SAFE: Transaction ensures all-or-nothing
async function transferMoneySafe(fromId, toId, amount) {
return knex.transaction(async (trx) => {
// Check balance first
const sender = await trx('users').where('id', fromId).first();
if (sender.balance < amount) {
throw new Error('Insufficient balance');
}
// Step 1: Deduct from sender
await trx('users').where('id', fromId).decrement('balance', amount);
// Step 2: Add to receiver
await trx('users').where('id', toId).increment('balance', amount);
// Both succeed or both fail together!
});
}
// Usage
try {
await transferMoneySafe(1, 2, 100);
console.log('Transfer successful');
} catch (error) {
console.error('Transfer failed:', error.message);
// Nothing changed - transaction rolled back
}
Key Point: If anything fails, the entire transaction rolls back. No partial updates!
5. Consistency - Following the Rules
Consistency means transactions take the database from one valid state to another. All rules (constraints) must be satisfied.
Enforcing Business Rules
// ✅ Transaction maintains consistency
async function createOrder(userId, items) {
return knex.transaction(async (trx) => {
// Rule 1: User must exist (referential integrity)
const user = await trx('users').where('id', userId).first();
if (!user) {
throw new Error('User not found');
}
// Rule 2: Calculate total
const total = items.reduce((sum, item) => {
return sum + (item.price * item.quantity);
}, 0);
// Rule 3: User must have enough balance
if (user.balance < total) {
throw new Error('Insufficient balance');
}
// Rule 4: Create order
const [orderId] = await trx('orders').insert({
user_id: userId,
total_amount: total,
status: 'pending'
});
// Rule 5: Add order items
for (const item of items) {
await trx('order_items').insert({
order_id: orderId,
product_name: item.product_name,
quantity: item.quantity,
price: item.price
});
}
// Rule 6: Deduct balance
await trx('users').where('id', userId).decrement('balance', total);
// All rules satisfied - commit!
return orderId;
});
}
// If ANY rule is violated, entire transaction fails
// Database remains in a consistent state
Database-Level Consistency
exports.up = function(knex) {
return knex.schema.createTable('products', (table) => {
table.increments('product_id').primary();
table.string('name').notNullable();
table.decimal('price', 10, 2).notNullable();
table.integer('stock').notNullable().defaultTo(0);
// Consistency rules enforced by database
table.check('price > 0'); // Price must be positive
table.check('stock >= 0'); // Stock can't be negative
});
};
// These rules are ALWAYS enforced, even if your code has bugs!
6. Isolation - No Interference
Isolation means concurrent transactions don't interfere with each other. Each transaction acts like it's alone.
The Problem Without Isolation
// ❌ RACE CONDITION: No isolation
async function purchaseProductBad(userId, productId, price) {
// User A and User B both run this at the same time
// Both read balance = $100
const user = await knex('users').where('id', userId).first();
console.log('Balance:', user.balance); // Both see $100
if (user.balance >= price) {
// Both pass the check!
// Both deduct $80
await knex('users').where('id', userId).decrement('balance', price);
// Final balance: $20 (should be -$60!)
// One purchase was "free" because of race condition 😱
}
}
The Solution With Isolation
// ✅ SAFE: Transaction with row locking
async function purchaseProductSafe(userId, productId, price) {
return knex.transaction(async (trx) => {
// Lock the user row - other transactions must wait
const user = await trx('users')
.where('id', userId)
.forUpdate() // 🔒 Exclusive lock
.first();
if (user.balance < price) {
throw new Error('Insufficient balance');
}
// Safe to deduct - we have exclusive access
await trx('users')
.where('id', userId)
.decrement('balance', price);
await trx('purchases').insert({
user_id: userId,
product_id: productId,
price: price
});
// Lock released when transaction completes
});
}
// Now purchases happen one at a time - no race condition!
Isolation Levels
MySQL supports different isolation levels. Default is Repeatable Read (good for most cases):
// Default isolation (Repeatable Read)
await knex.transaction(async (trx) => {
// Your queries here
});
// Specify isolation level if needed
await knex.transaction({ isolationLevel: 'read committed' }, async (trx) => {
// Your queries here
});
Common Levels:
- Read Committed - See committed changes from other transactions
- Repeatable Read - Same query always returns same results (MySQL default)
- Serializable - Strongest isolation, like transactions run one after another
For beginners: Just use the default (Repeatable Read) and add forUpdate() when reading data you'll modify.
7. Durability - Data Survives
Durability means once a transaction commits, the changes are permanent - even if the power goes out!
How Durability Works
async function updateBalance(userId, newBalance) {
await knex.transaction(async (trx) => {
await trx('users')
.where('id', userId)
.update({ balance: newBalance });
// Transaction commits here
});
console.log('Balance updated');
// 💥 Even if the app crashes RIGHT NOW...
// 💥 Even if the server loses power...
// ✅ The balance change is still saved!
}
Behind the scenes: MySQL writes to a transaction log before committing. Even if the database crashes, it can recover from the log on restart.
Testing Durability
async function testDurability() {
// Update balance
await knex('users')
.where('id', 1)
.update({ balance: 1000 });
console.log('Updated balance to 1000');
// Query to verify
const user = await knex('users').where('id', 1).first();
console.log('Current balance:', user.balance); // 1000
// Stop and restart MySQL server
// Query again - still 1000! ✅
}
You don't need to do anything special - durability is automatic once a transaction commits!
8. Quick Reference
ACID Checklist
Before deploying code that modifies data:
- ✅ Using transactions for multi-step operations (Atomicity)
- ✅ All constraints defined in schema (Consistency)
- ✅ Using
forUpdate()when reading then modifying (Isolation) - ✅ Proper error handling and rollback logic (Atomicity)
- ✅ Foreign keys enforce relationships (Consistency)
Constraint Types
| Constraint | Purpose | Example |
|---|---|---|
| PRIMARY KEY | Unique identifier | table.increments('id').primary() |
| UNIQUE | No duplicates | table.string('email').unique() |
| NOT NULL | Must have value | table.string('name').notNullable() |
| DEFAULT | Auto-fill if empty | table.decimal('balance').defaultTo(0) |
| CHECK | Custom rule | table.check('age >= 18') |
| FOREIGN KEY | Valid reference | table.foreign('user_id').references('id').inTable('users') |
Common Constraint Errors
// Handle constraint violations
try {
await knex('users').insert({ email: 'test@example.com', username: 'test' });
} catch (error) {
if (error.code === 'ER_DUP_ENTRY') {
console.error('Duplicate email or username');
} else if (error.code === 'ER_CHECK_CONSTRAINT_VIOLATED') {
console.error('Check constraint failed (e.g., negative balance)');
} else if (error.code === 'ER_NO_REFERENCED_ROW_2') {
console.error('Foreign key violation (referenced record does not exist)');
} else {
throw error; // Unknown error
}
}
Transaction Pattern
Standard pattern for any multi-step operation:
async function doSomething() {
return knex.transaction(async (trx) => {
// 1. Read data (lock if needed)
const record = await trx('table')
.where('id', 1)
.forUpdate() // Lock if you'll modify based on current value
.first();
// 2. Validate business rules
if (/* some condition */) {
throw new Error('Validation failed');
}
// 3. Make changes
await trx('table').where('id', 1).update({ /* ... */ });
await trx('other_table').insert({ /* ... */ });
// 4. Return result (auto-commits if no error)
return result;
});
}
The Golden Rules
- Use transactions for related operations - Ensure atomicity
- Define constraints in the database - Don't rely only on app code
- Lock rows you'll modify - Use
forUpdate()to prevent race conditions - Handle constraint violations gracefully - Catch and show user-friendly errors
- Let the database do the work - ACID is automatic when you use transactions
- Test failure scenarios - Make sure rollback works as expected
- Keep transactions short - Acquire locks, do work, release quickly
Common Patterns
Pattern 1: Money Transfer
async function transfer(fromId, toId, amount) {
return knex.transaction(async (trx) => {
// Lock both accounts
const [from, to] = await Promise.all([
trx('accounts').where('id', fromId).forUpdate().first(),
trx('accounts').where('id', toId).forUpdate().first()
]);
// Validate
if (from.balance < amount) {
throw new Error('Insufficient funds');
}
// Transfer
await trx('accounts').where('id', fromId).decrement('balance', amount);
await trx('accounts').where('id', toId).increment('balance', amount);
// Log transaction
await trx('transfers').insert({ from_id: fromId, to_id: toId, amount });
});
}
Pattern 2: Create with Validation
async function createUser(data) {
// Constraints handle validation automatically
try {
const [userId] = await knex('users').insert({
email: data.email, // UNIQUE constraint
username: data.username, // NOT NULL constraint
balance: data.balance || 0 // CHECK constraint (>= 0)
});
return userId;
} catch (error) {
// Convert database errors to user-friendly messages
if (error.code === 'ER_DUP_ENTRY') {
throw new Error('Email or username already taken');
}
throw error;
}
}
Pattern 3: Update with Validation
async function updateProductStock(productId, quantity) {
return knex.transaction(async (trx) => {
const product = await trx('products')
.where('id', productId)
.forUpdate()
.first();
if (!product) {
throw new Error('Product not found');
}
if (product.stock < quantity) {
throw new Error('Not enough stock');
}
await trx('products')
.where('id', productId)
.decrement('stock', quantity);
});
}
Testing Your Integrity
Quick tests to verify ACID properties:
async function testAcidProperties() {
// Test 1: Atomicity
console.log('Testing atomicity...');
try {
await knex.transaction(async (trx) => {
await trx('users').insert({ email: 'test@example.com', username: 'test' });
throw new Error('Simulated failure');
await trx('orders').insert({ user_id: 1 }); // Never executes
});
} catch (error) {
const user = await knex('users').where('email', 'test@example.com').first();
console.log('User should not exist:', user === undefined); // true ✅
}
// Test 2: Consistency (Constraints)
console.log('Testing constraints...');
try {
await knex('users').insert({ email: 'bad@example.com', balance: -100 });
} catch (error) {
console.log('Negative balance rejected:', error.code === 'ER_CHECK_CONSTRAINT_VIOLATED'); // true ✅
}
// Test 3: Isolation (No race condition)
console.log('Testing isolation...');
await Promise.all([
purchaseProductSafe(1, 10, 50),
purchaseProductSafe(1, 11, 50)
]);
const user = await knex('users').where('id', 1).first();
console.log('Balance correct after concurrent purchases:', user.balance === 0); // true ✅
}
Next Steps
- Add constraints to your existing tables (UNIQUE, CHECK, etc.)
- Identify operations that need transactions (multi-step processes)
- Add
forUpdate()to queries where you read-then-modify - Test your error handling (duplicate emails, negative balances, etc.)
- Verify foreign key relationships are working
Remember: The database is your partner in maintaining data integrity. Use its built-in features (constraints, transactions, foreign keys) rather than trying to handle everything in application code!
ACID properties work automatically when you use transactions properly - no magic required! 🎉