Databases 101: Tables, Indexes and Transactions
Open in the interactive app: practice, quiz, mock interview →In one minute
- A relational database stores data in tables: rows are records, columns are typed fields, and a primary key names each row uniquely. You ask questions in SQL by describing the result you want, and the database works out how to get it.
- Without an index, finding rows means reading the whole table. An index is a sorted copy of one or more columns (usually a B-tree) that turns a scan of millions of rows into a handful of page reads. Every index speeds up some reads and slows down every write.
- A transaction groups several changes so they all happen or none do, even if the server crashes halfway. ACID names the promises: atomicity, consistency, isolation and durability.
- Durability comes from a write-ahead log: the database appends the change to a log file and calls
fsyncbefore saying "committed". The table files themselves are updated later. - Two problem shapes live here. Indexes are "finding things fast"; transactions handle "contention" and "multi-step consistency". You will meet both again in caches, search engines, queues and payment systems.
How it works
Tables, rows and keys
Picture a shop with two tables:
CREATE TABLE customers (
id BIGINT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
city TEXT
);
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(id),
total_cents INTEGER NOT NULL CHECK (total_cents >= 0),
created_at TIMESTAMP NOT NULL
);Each order points to its customer through customer_id, a foreign key. The database enforces the rules you declare: no two customers share an email, no order points to a customer who does not exist, no total is negative. Rules in the schema hold no matter which service, script or engineer writes the data, which is why they are worth more than the same check in application code.
Asking questions with SQL
SQL is declarative. You say what you want:
SELECT c.email, SUM(o.total_cents) AS spent
FROM orders o JOIN customers c ON c.id = o.customer_id
WHERE o.created_at >= '2026-01-01'
GROUP BY c.email ORDER BY spent DESC LIMIT 10;A component called the query planner decides how: which table to read first, whether to use an index, and which join algorithm to run. EXPLAIN shows you the plan it chose. Learning to read it is the single most useful database skill for an application engineer.
Indexes
A table's rows sit in no useful order. To find the orders of customer 42, the database has to read every row unless it has an index on customer_id. An index works like the index at the back of a book: a sorted list of values, each pointing to where the full row lives. Because it is sorted, the database can jump to the right place in a few steps, and it can also answer range queries (created_at between two dates) and ORDER BY without sorting.
The price is paid on writes. Every insert, and every update to an indexed column, must also update each index, and each index takes disk space and memory. A table with eight indexes does nine writes for one insert.
Transactions and ACID
Moving 100 dollars from Ana to Budi is two updates. If the server crashes between them, money vanishes. A transaction wraps them:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1 AND balance >= 100;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;- Atomicity: both updates happen or neither does.
ROLLBACK, or a crash beforeCOMMIT, undoes everything. - Consistency: the declared rules (keys,
CHECK,NOT NULL) hold after every committed transaction. - Isolation: concurrent transactions do not see each other's half-finished work. How strictly this holds depends on the isolation level.
- Durability: once
COMMITreturns, the change survives a crash or power cut.
Concurrent writers
Two requests read a stock count of 1, both subtract one, both write 0, and two customers buy the last item. This is a lost update, and the default isolation level in Postgres, Read Committed, does not prevent it when the read and the write are separate statements. A single UPDATE is safe there, because a blocked updater re-checks its WHERE clause against the newly committed row (Postgres docs). The fixes: do the check and the change in one statement (UPDATE stock SET qty = qty - 1 WHERE id = 7 AND qty > 0, then check that one row changed), lock the row first with SELECT ... FOR UPDATE, or run at Serializable isolation and retry when the database reports a conflict.
The same shapes elsewhere
An index is "finding things fast", the same idea as a search engine's inverted index or a geospatial index for nearby drivers: pay at write time to build a structure that makes reads cheap. A transaction is "multi-step consistency": order, stock and payment must agree. When those steps span several services instead of one database, you no longer get BEGIN and COMMIT for free and need patterns such as sagas and the outbox, which later courses cover.
Down the stack
Layer 1The contract
When COMMIT returns successfully, the transaction's changes are durable and visible to transactions that start afterwards. If the client loses its connection before COMMIT returns, the outcome is unknown, just as with an HTTP timeout, so it must check rather than blindly retry. The isolation level defines what concurrent transactions may observe: Read Committed (the Postgres default) shows each statement only committed data but lets a second read in the same transaction see newer commits; Repeatable Read keeps one snapshot for the whole transaction and is the MySQL InnoDB default; Serializable promises the result equals some one-at-a-time order, aborting transactions when it cannot. Postgres reports those aborts, and Repeatable Read update conflicts too, as SQLSTATE 40001, and its docs say to retry the whole transaction (Postgres docs).
Layer 2The mechanism
Postgres stores each table as a heap file of 8 KB pages, and each row version carries a 23-byte header with t_xmin and t_xmax: the IDs of the transactions that created and deleted it (Postgres docs). An update writes a new row version instead of overwriting, so readers see the version that matches their snapshot without taking locks (MVCC, multi-version concurrency control). VACUUM later reclaims versions nobody can see. Indexes are B-trees: pages of sorted keys, where inner pages point to child pages and leaf pages point to row locations (a page number and item slot, the ctid). Before any data page changes, the database appends a record describing the change to the write-ahead log in pg_wal, in segment files that default to 16 MB. MySQL's InnoDB uses 16 KB pages, keeps rows inside the primary key B-tree itself, and stores old versions in an undo log that ROLLBACK replays backwards.
Layer 3The machine
A B-tree page of 8 KB holds a few hundred keys, so a tree only 3 levels deep covers tens of millions of rows and 4 levels covers billions. The top levels are read so often that they stay in the database's buffer pool in RAM; a lookup typically costs one or two real disk reads, each about 100 µs on an NVMe SSD, or nothing if the pages are cached. A full scan of a 10 GB table reads at perhaps 2 GB/s from SSD: about 5 seconds. On commit, the WAL is written sequentially and fsync forces it to stable storage, typically 0.05 to 2 ms depending on the drive. Databases batch the commits of many concurrent transactions into one fsync (group commit; Postgres exposes commit_delay and commit_siblings for it, see the WAL docs), which is how a single server sustains tens of thousands of commits per second. The ordering matters as much as the flush: SQLite's atomic commit write-up shows the journal must be durable before the database file changes, because the OS may reorder writes.
Why it's built this way
- Disks and SSDs are slow at random small writes and fast at sequential ones, so commits append to a log instead of updating table pages in place.
- A log alone is slow to search, so data also lives in pages with B-tree indexes, updated lazily and rebuilt from the log after a crash.
- Disk reads come in pages, so B-trees are wide and shallow, packing hundreds of keys into each page to minimise reads per lookup.
- Locking every row a reader touches would make readers block writers, so engines keep multiple versions and give each transaction a snapshot.
- Old versions pile up, so the engine needs background cleanup (
VACUUM, undo purge), and long-running transactions that pin old snapshots make that cleanup fall behind.
Diagram
flowchart TD C["Client: SQL"] --> P["Parser and planner"] P -->|"index chosen"| B["B-tree index"] P -->|"no useful index"| S["Full table scan"] B --> H["Table pages (heap)"] S --> H H --> BP["Buffer pool in RAM"] BP -->|miss| D["Data files on SSD"] C -->|"COMMIT"| W["Append to WAL"] W --> F["fsync, then reply OK"] F -.->|"later checkpoint"| D
Worked example
An orders table has 50 million rows of about 200 bytes each, so about 10 GB. The customer page runs SELECT * FROM orders WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 20. With no index on customer_id, EXPLAIN shows a sequential scan: the database reads all 10 GB, about 5 seconds from SSD, and the query blocks a connection the whole time. At 50 requests per second this would need hundreds of seconds of disk reading per second, so the site falls over.
Add CREATE INDEX ON orders (customer_id, created_at). The B-tree has 4 levels. The database walks from the root to the first leaf entry for customer 42 (the upper levels are cached, so maybe one disk read), reads the 20 newest entries already in created_at order, and fetches 20 rows from the heap. That is about 20 random page reads, under 3 ms even with a cold cache, and no sort. The query got about 2,000 times faster.
The cost: the index is about 1.5 GB, and each of the 300 inserts per second now also writes to it. That is fine here. Ten more indexes "just in case" would multiply write work and memory pressure, so index for the queries you actually run.
Now the payment path. Checkout inserts an order, decrements stock and records a payment. Inside one transaction, a crash after the stock update rolls all three back. Outside one, the shop can sell stock it never took payment for, and someone has to write a reconciliation script to find those rows.
In the wild
- Sentry, 2015: Postgres transaction-ID wraparound. On July 20, 2015 Sentry's Postgres stopped accepting writes as it neared transaction-ID wraparound, because autovacuum was too lax and one run never finished a large table. The service ran read-only for hours; the fix was to free XIDs by truncating a large table, then tune autovacuum aggressively (Sentry engineering blog).
- Mailchimp (Mandrill), 2019: wraparound on a hot shard. One shard took a disproportionate share of writes, autovacuum fell behind and the database shut itself down. A standalone VACUUM was estimated at days to weeks, so the team truncated two tables; the outage lasted roughly two days, from the 05:35 UTC shutdown on February 4 to normal operation after 01:00 UTC on February 6 (Mailchimp).
- Uber, 2016: write amplification from indexes. Each row update in Postgres wrote new entries to every index, and physical WAL replication carried all of it across data centers. Uber moved most databases to MySQL (Uber Engineering).
Trade-offs
| Choice | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| No index on a column | Fast writes, no extra space | Every lookup scans the table | Small tables, columns never filtered on |
| B-tree index | Fast equality, range and sorted reads | Slower writes, uses memory and disk | Columns in frequent WHERE, JOIN, ORDER BY |
| Composite index (a, b) | One index serves filter plus sort | Useless for queries on b alone | Queries that filter on a and sort by b |
| Read Committed | Few conflicts, good throughput | Lost updates possible without care | Default for most web workloads |
| Serializable | Behaves like one transaction at a time | Aborts under conflict, needs retry logic | Money, inventory, invariants across rows |
| Relational database | Schema, constraints, joins, transactions | Harder to scale writes beyond one node | Default choice for business data |
| Key-value store | Simple, very fast single-key access | No joins, weak multi-key transactions | Caches, sessions, very high write rates |
Pitfalls in production
- Missing index on a foreign key or a common filter, found only when the table grows. Fix: check
EXPLAINfor new queries in review and watch slow-query logs. - Wrapping an indexed column in a function, as in
WHERE lower(email) = ..., so the index cannot be used. Fix: compare the raw column, or create an index on the expression. - Read-modify-write in application code (read the balance, compute, write it back), losing updates under concurrency. Fix: a single conditional
UPDATE,SELECT ... FOR UPDATE, or a version column checked on write. - Long transactions, such as one held open across an HTTP call, that hold locks and stop cleanup of old row versions. Fix: keep transactions short and never wait on the network inside one. In Postgres this also protects against transaction-ID wraparound, which is a hard write stop when vacuum cannot keep up.
CREATE INDEXon a large live table blocking writes for minutes. Fix: use the online variant (CREATE INDEX CONCURRENTLYin Postgres), which scans the table twice, cannot run inside a transaction block, and on failure leaves anINVALIDindex that you must drop or rebuild (Postgres docs). Schedule migrations deliberately.- Retrying a failed commit without knowing whether it succeeded, creating duplicate rows. Fix: a unique key on a client-supplied request ID so the retry fails harmlessly.
Leadership lens
- Make the schema part of design review: constraints, keys and indexes are cheap to get right at the start and expensive to change on a table with a billion rows.
- Ask for the query list before approving indexes; every index should name the query it serves.
- Set a team default isolation level and a rule for which code paths (money, stock, quotas) need row locks or Serializable with retries.
- Treat migrations on large tables as production changes with a rollout plan, not as routine deploy steps.
Check yourself
Why does adding an index make reads faster but writes slower?
Show answer
The index is a sorted structure the database can search in a few page reads instead of scanning every row, but every insert or change to the indexed column must also update that structure.
Two requests both run "read stock, subtract one, write stock" at Read Committed. What can go wrong and how do you fix it?
Show answer
Both can read the same value and one write overwrites the other, selling stock twice. Do the check and subtraction in one conditional
UPDATE, lock the row withSELECT ... FOR UPDATE, or use Serializable and retry on conflict.How can a database promise durability on commit without writing every changed table page to disk?
Show answer
It appends a description of the changes to the write-ahead log and
fsyncs that sequential log before replying; after a crash it replays the log to rebuild the pages.Why can a B-tree over a billion rows answer a lookup in about four page reads?
Show answer
Each page holds hundreds of keys, so each level multiplies coverage by hundreds; four levels of a few hundred keys each reach billions of entries.
Sources and further study
Primary
- Postgres: Transaction Isolation: exact behaviour of Read Committed, Repeatable Read and Serializable, including retry guidance.
- SQLite: Atomic Commit: the rollback-journal protocol and its write-ordering assumptions.
Systems docs
- Postgres: Database Page Layout: 8 kB pages, tuple headers,
xmin,xmax,ctid. - Postgres: CREATE INDEX: the
CONCURRENTLYbuild phases and failure behaviour. - Postgres: WAL configuration: checkpoints,
fsync, group commit settings. - MySQL 8.4: InnoDB isolation levels and physical structure: defaults and page size.
Books
- Designing Data-Intensive Applications (1st ed.) by Martin Kleppmann, Ch 3: Storage and Retrieval, and Ch 7: Transactions (2nd ed.: Ch 4 and Ch 8).
- Database Internals by Alex Petrov, Ch 2: B-Tree Basics, and Ch 5: Transaction Processing and Recovery.
Other curricula
- System design primer: Database: frames the topic as RDBMS ACID, replication and scaling choices, and treats indexes as a read-versus-write trade.
Watch
- Database Indexing Explained (with PostgreSQL) (Hussein Nasser): builds a large table, runs queries with and without indexes and reads the plans.
- SQL Tutorial - Full Database Course for Beginners (freeCodeCamp.org): a long, gentle introduction to tables, keys, queries and joins.
Free resources
- Use The Index, Luke (Markus Winand): the best free guide to how B-tree indexes work and how to write queries that use them.
- PostgreSQL tutorial: official hands-on introduction to tables, queries, joins and transactions.
- PostgreSQL: Indexes: index types, composite indexes and when the planner uses them.
- PostgreSQL: Transaction Isolation: precise definitions of each isolation level and the anomalies it allows.
- Atomic Commit in SQLite: a step-by-step account of how a real database survives a crash mid-commit.
- CMU 15-445/645 Database Systems: free lectures and notes on storage, indexes, transactions and recovery.