Chapter 7 — Key Takeaways (Relational Databases for Data Engineering)
The page for reading from someone else's production database without becoming an incident.
You are a guest
No adding indexes · no changing schemas · competing for the same cache and I/O · subject to their maintenance · a read-only query can still cause an outage · you will be blamed because you are the unusual workload.
| Read from | When |
|---|---|
| The primary | small, bounded, indexed lookups only |
| A read replica | the default for extraction — costs lag, and see hot_standby_feedback below |
| A restored snapshot | enormous full loads; compliance restrictions on touching production |
How a row store reads
8 KB page = the unit of I/O. Rows stored whole. Reading one column of 6M rows reads all 40 columns of all 6M rows. That asymmetry is the entire reason columnar storage exists.
| Access path | Selectivity |
|---|---|
| Index scan | few rows — each heap fetch is a random read |
| Bitmap heap scan | moderate — collect locations, sort by page, read in order |
| Sequential scan | many rows — and the planner is usually right to choose it |
MVCC — the counter-intuitive outage
Readers don't block writers. And old row versions cannot be reclaimed while any transaction might still need them.
09:00 BEGIN; SELECT ... snapshot taken
11:30 4.1M rows updated since. Every superseded version still on disk.
VACUUM can remove none of them. Table 40 GB → 61 GB.
Sequential scans read 50% more pages. Checkout latency rises.
⚠️ The hot_standby_feedback trap
| Setting | Your long query | The primary |
|---|---|---|
off (default) |
cancelled — "conflict with recovery" | unaffected |
on |
survives | bloats, ∝ duration × write rate |
SHOW hot_standby_feedback; — ten seconds, and most people have never run it on a replica they
extract from daily. If on, your statement timeout is protecting the primary.
The five defenses — use all five
statement_timeoutin the connection string, not in application codeidle_in_transaction_session_timeout— an idle open transaction is worse than a running query- Read a replica (knowing which
hot_standby_feedbackyou have) - Chunk — a new connection per chunk, so each snapshot is short
- Alert on oldest transaction age and on
n_dead_tup / n_live_tup
Off-peak scheduling protects against contention, not duration. A 02:00 job running six hours runs at peak.
Types
Money is integer cents
>>> 0.1 + 0.2
0.30000000000000004
BIGINT cents (this book) or NUMERIC(19,4) (when you need fractional cents). Never a float
— a reconcile-to-the-cent criterion is unmeetable by construction.
⚠️ The float enters through the percentage, not the money column.
discount_percent DOUBLE PRECISION × unit_price_cents→ float. An exact system is only exact if every operand is exact. Store rates as basis points; make the rounding rule explicit and single.
The rest
| Rule | |
|---|---|
| Timestamps | timestamptz, store UTC, convert only at presentation. Otherwise: 23- and 25-hour days at DST, wrong twice a year, dismissed as noise |
| Identifiers | BIGINT or UUID v7. v4's randomness destroys index locality on insert |
| Semi-structured | JSONB for genuinely schemaless. Extract the same key in >3 places → it should be a column |
Indexes from the reader's side
- Composite indexes work left to right.
(customer_id, placed_at)servescustomer_id = ?and the pair; it does not serveplaced_at > ?alone. - A function on the column defeats the index — same failure as a function on a partition column.
- Ask for a BRIN index by name on a large append-only timestamp: tiny, barely touches the write path, and therefore the request most likely to be approved.
Reading a plan — EXPLAIN (ANALYZE, BUFFERS)
Four checks, in order:
Execution Timeat the bottom — is it what you expected?- Largest
actual timenode, reading bottom-up — that's where the time is rows=estimated vs. actual on that node — off by 10× means runANALYZE; you are debugging statistics, not a queryBuffers: read=— pages from disk × 8 KB = the real I/O
⚠️ Bare EXPLAIN shows estimates only. EXPLAIN ANALYZE on a DELETE executes it — wrap it in a
rolled-back transaction.
The safe extract — six load-bearing properties
upper = primary.execute("SELECT now() - interval '30 seconds'") # from the PRIMARY
lower = watermark - timedelta(minutes=5) # deliberate overlap
while lower < upper:
end = min(lower + CHUNK, upper)
with psycopg.connect(REPLICA_DSN) as conn: # new connection per chunk
cur.execute("SET statement_timeout = 1800000")
cur.execute("SET idle_in_transaction_session_timeout = 60000")
cur.execute("... WHERE updated_at >= %s AND updated_at < %s", (lower, end))
land_idempotently(rows)
lower = end
write_watermark(upper) # the RANGE's bound, not max() of what was read
- Replica, not primary · 2. Upper bound from the primary · 3. Chunked, fresh connection each ·
- Statement + idle timeouts · 5. Half-open interval
[lo, hi)· 6. Watermark = the range bound, so an empty chunk still advances
Replication
| Physical | Logical | |
|---|---|---|
| Granularity | whole cluster | selected tables |
| Cross-version / cross-engine | no | yes |
wal_level |
replica |
logical |
| Used for | read replicas, failover | CDC |
⚠️ A logical replication slot with a stopped consumer retains WAL without bound. Disk fills → the primary stops accepting writes → checkout stops. Set
max_slot_wal_keep_size· monitor slot lag · alert onactive = false· drop unused slots.
PostgreSQL vs. MySQL — what affects you
| PostgreSQL | MySQL/InnoDB | |
|---|---|---|
| Long transactions | table bloat (needs vacuum) | undo log growth |
| Table storage | heap; PK is a separate index | clustered on PK → range your extract on the PK |
| CDC | logical decoding + slot | binlog, ROW format |
| The trap | bloat | utf8 is not UTF-8 — you want utf8mb4 (symptom: mysteriously missing rows) |
The query to run against any database you inherit
SELECT table_name, column_name, data_type
FROM information_schema.columns
WHERE column_name LIKE '%_cents' AND data_type NOT IN ('bigint','integer');
-- and then: money-ish names with no unit at all
-- price / amount / total / revenue / cost NOT LIKE '%_cents'
Zero rows, or you have a finding.