πŸ“š Study Notes / Home / DBMS / Session 15
Session 15 Β· Modern Architectures

How modern databases store and scan data fast

For most of this course we treated a database as one kind of thing β€” tables on disk, a B+ tree index, transactions. But the engines powering real systems today (RocksDB, Cassandra, DuckDB, ClickHouse) made very different design choices, on purpose. This session unpacks four of them from scratch: LSM trees for fast writes, columnar storage for fast analytics, the DuckDB engine, and how RocksDB compaction works. We assume you've studied none of this β€” every topic starts with a tiny story.

⏱ 23 min readπŸ“– 4 topics

1 LSM trees β€” write-optimized storage


Explain like I'm 5

Imagine you're taking notes super fast during a lecture. You don't carefully file each note into the right binder while the teacher is talking β€” that would be way too slow. Instead you scribble everything onto a fresh notepad as it comes, in order. Later, when you have a quiet moment, you tidy the scribbles into neat, sorted folders. An LSM tree works exactly like that: write fast and messy now, tidy up later.

An LSM tree (Log-Structured Merge-tree) is a way of organising data on disk that is built to make writes extremely fast. The name has three parts: log-structured (new data is appended like a log, never overwritten in place), merge (sorted pieces get merged together over time), and tree (the merged pieces form layers, loosely like a tree of levels). It is the storage engine behind RocksDB, Cassandra, LevelDB, ScyllaDB, and many others.

Where you've seen the pieces before

Recall the write-ahead log (WAL) from Session 14 (Logging & Recovery) β€” append-only, sequential, crash-safe. LSM trees take that "appending is cheap and safe" idea and make it the whole storage strategy, not just the recovery log. The HLD subject also covers LSM trees from a systems-design angle; here we keep the database-engine lens.

The three moving parts

An LSM engine has three core components. Let's define each.

  • MemTable β€” an in-memory, sorted structure (often a skip list or balanced tree) that absorbs all incoming writes. It's fast because RAM is fast and it stays sorted so it can be flushed in order.
  • SSTable (Sorted String Table) β€” an immutable file on disk holding keyβ†’value pairs in sorted order. "Immutable" means once written it is never edited β€” only replaced by a later merge. This is the on-disk home of your data.
  • Compaction β€” a background job that merges several SSTables into fewer, larger, still-sorted SSTables, throwing away stale/overwritten entries. This is the "tidy up later" step (covered in depth in Topic 4).

The write path, step by step

πŸ“
1. WAL append
Write to log for crash safety
β†’
🧠
2. MemTable
Insert into sorted RAM table
β†’
πŸ’Ύ
3. Flush
When full, write out as an SSTable
β†’
🧹
4. Compact
Merge SSTables in the background

The magic is that every write is just an append: into the WAL (sequential disk write) and into the MemTable (in-memory). There is no hunting around the disk to find the exact spot to overwrite. Sequential writes are dramatically faster than random writes on both spinning disks and SSDs, which is why LSM engines shine at write-heavy workloads (logging, time-series, messaging, metrics).

How do updates and deletes work if files are immutable?

You never edit an SSTable. To update a key, you just write the new value β€” it lands in the MemTable and a newer SSTable, and reads always take the newest version. To delete, you write a special marker called a tombstone that says "this key is gone." The old value still physically exists until compaction finally drops both it and the tombstone. So even deletes are writes!

The read path β€” and why it's harder

Reading is the trade-off. A key might live in the MemTable, or in any of several SSTables across several levels. A read must check them newest-first until it finds the key:

  1. Check the MemTable (RAM β€” fast).
  2. Check each SSTable, newest to oldest, until found.

To avoid touching every file, LSM engines use two tricks. A Bloom filter per SSTable β€” a tiny probabilistic structure that can say "this key is definitely not here" (so you skip the file entirely) or "maybe here." And a sparse index / block index that maps key ranges to file offsets so a lookup reads only one small block. Because each SSTable is sorted, a binary search inside the right block finishes the job.

Worked example β€” a tiny LSM key-value store

Watch the same key get written, updated, and read. Operations arrive top to bottom:

# Writes arrive (each is just an append):
PUT user:42 = "Ann"      # goes to MemTable
PUT user:99 = "Bo"       # goes to MemTable
# MemTable fills up β†’ flushed to disk as SSTable-1 (sorted):
SSTable-1 = { user:42 -> "Ann", user:99 -> "Bo" }

PUT user:42 = "Annie"    # NEW value, just appended to MemTable
DELETE user:99           # writes a tombstone, not an erase
# MemTable flushed again β†’ SSTable-2 (newer):
SSTable-2 = { user:42 -> "Annie", user:99 -> <tombstone> }

# READ user:42  β†’ check MemTable (miss), then SSTable-2 (newest)
#   β†’ finds "Annie". Stops. Never even looks at SSTable-1.
# READ user:99  β†’ SSTable-2 has a tombstone β†’ returns "not found".

# Later, COMPACTION merges SSTable-1 + SSTable-2 into one:
SSTable-3 = { user:42 -> "Annie" }   # old "Ann" & the tombstone are dropped

Notice: nothing was ever overwritten in place. New facts simply shadow old ones, and compaction eventually reclaims the space. That is the whole LSM philosophy in one example.

LSM vs B+ tree β€” write-optimized vs read-optimized

Recall the B+ tree from our indexing session: a balanced tree kept sorted and updated in place. It's the classic engine behind PostgreSQL, MySQL/InnoDB, and most traditional databases. Here's the fundamental contrast:

AspectB+ tree (read-optimized)LSM tree (write-optimized)
WritesUpdate a page in place β†’ often a random disk write; may split pages.Append to log + MemTable β†’ sequential, very fast.
Point readsOne tree traversal, data lives in exactly one place. Predictable, fast.May check MemTable + several SSTables (mitigated by Bloom filters).
Range scansExcellent β€” leaves are linked in sorted order.Good, but must merge sorted runs from multiple files.
SpaceCompact; some fragmentation from page splits.Temporary duplicates (old + new) until compaction; tombstones linger.
Best forRead-heavy, mixed OLTP, predictable latency.Write-heavy: logs, metrics, time-series, high ingest.
The catch: amplification

LSM's "write now, tidy later" isn't free. Compaction re-writes data multiple times as it moves down the levels β€” this is write amplification. And one logical read may touch several files β€” read amplification. We unpack these trade-offs and how to tune them in Topic 4.

Recap An LSM tree turns every write into a cheap append: into a WAL and an in-memory MemTable, flushed to immutable sorted SSTables, with background compaction merging and cleaning them. Updates shadow old values; deletes write tombstones. It trades slightly harder reads (helped by Bloom filters) for blazing-fast writes β€” the opposite balance from an in-place B+ tree.

2 Row vs columnar storage


Explain like I'm 5

Imagine a big class register. You could store it as one card per student with all their info on it (name, age, grade, address) β€” grab a card and you know everything about one kid. Or you could store it as separate lists: a list of all names, a list of all ages, a list of all grades. If the teacher asks "what's the average age?", the second way is way easier β€” just read the ages list and ignore everything else. Row storage is one-card-per-kid; columnar storage is one-list-per-thing.

How a database physically lays bytes on disk decides what it's good at. There are two layouts.

  • Row storage (row-oriented) β€” all the columns of one row are stored together, then the next row, and so on. Great when you want whole records.
  • Columnar storage (column-oriented) β€” all the values of one column are stored together, then the next column. Great when you want one column across many rows.

OLTP vs OLAP β€” two different jobs

This connects to two workload types you should know cold:

OLTPOLAP
Stands forOnline Transaction ProcessingOnline Analytical Processing
Typical query"Fetch / update this one order.""Sum revenue by month across 2 billion rows."
TouchesFew rows, all their columns.Few columns, huge number of rows.
PatternMany small reads/writes.Big scans & aggregations, mostly read-only.
Best layoutRow (e.g. PostgreSQL, MySQL).Columnar (e.g. DuckDB, ClickHouse, Snowflake).

Why analytics scans favour columnar β€” the picture

Imagine a sales table with columns id, customer, region, amount and millions of rows. Here's how the two layouts store the same first three rows on disk:

# ROW layout β€” fields of a row are adjacent:
[ 1, "Ann", "EU", 50 ] [ 2, "Bo", "US", 80 ] [ 3, "Cy", "EU", 20 ] ...

# COLUMNAR layout β€” same field across rows is adjacent:
id:       [ 1, 2, 3, ... ]
customer: [ "Ann", "Bo", "Cy", ... ]
region:   [ "EU", "US", "EU", ... ]
amount:   [ 50, 80, 20, ... ]

Now run SELECT SUM(amount) FROM sales;. The database reads data off disk in fixed-size blocks. In the row layout, every block you read is stuffed with id, customer, and region bytes you don't care about β€” you drag the whole row in just to grab one number. In the columnar layout, the amount values sit packed together, so you read only that column and nothing else. On a billion-row table that's the difference between reading 4 columns' worth of bytes and reading 1 β€” often a 5–50Γ— reduction in I/O.

Key takeaway

Analytics queries are narrow but deep: a few columns, billions of rows. Columnar layout reads only the columns a query touches, so it moves far less data off disk. That single property β€” I/O proportional to columns used, not columns stored β€” is the heart of every analytical database.

Bonus 1: compression gets way better

When values of the same type and meaning sit next to each other, they compress spectacularly β€” far better than a jumble of mixed-type row data. Two classic schemes:

  • Run-Length Encoding (RLE) β€” replace a run of repeated values with (value, count). A region column that's sorted into long stretches of "EU" becomes ("EU", 1_000_000) instead of a million copies.
  • Dictionary encoding β€” build a small dictionary of distinct values and store cheap integer codes instead of the full values. Great for low-cardinality columns (few distinct values) like region, status, or country codes.
Worked example β€” dictionary + RLE on a region column
# Raw region column (lots of repetition):
["EU","EU","EU","US","US","EU","EU","EU","EU","ASIA"]

# Step 1 β€” Dictionary encoding (map strings β†’ small ints):
dict   = { 0: "EU", 1: "US", 2: "ASIA" }
codes  = [0,0,0,1,1,0,0,0,0,2]      # tiny ints instead of strings

# Step 2 β€” Run-Length Encode the codes:
rle    = [(0,3), (1,2), (0,4), (2,1)]   # (value, run-length)

# 10 strings  β†’  4 little pairs. And a filter like
# WHERE region = 'EU' becomes "where code == 0" β€” integer compares, fast.

Less disk read, less memory, and faster comparisons. Compression here isn't just about saving space β€” smaller data means fewer bytes to move, which directly speeds up the scan.

Bonus 2: vectorized execution

The second columnar superpower is how the data gets processed. Traditional row engines use the Volcano / tuple-at-a-time model: ask for one row, process it, ask for the next β€” one row through the whole pipeline at a time, with a function call per row. That's a lot of overhead per value.

Columnar engines instead use vectorized execution: process data in batches (vectors) of a few thousand values from one column at a time. One tight loop runs over a whole batch. This is cache-friendly, branch-predictor-friendly, and lets the CPU use SIMD (Single Instruction, Multiple Data β€” one CPU instruction adds many numbers at once). The overhead per row collapses.

Why vectorized + columnar are a perfect match

Columnar storage hands you a contiguous array of one column's values β€” exactly the shape a tight vectorized loop (and SIMD) wants. Row storage would force you to gather one field out of each scattered row first. So columnar layout and vectorized execution reinforce each other: the storage format feeds the execution model. DuckDB (next topic) is built on precisely this pairing.

Don't use columnar for OLTP

The flip side: fetching or updating one whole row in a columnar store means touching every column file separately, and inserting a single row is awkward when data is packed and compressed by column. For "give me order #12345" or "update this user's email," a row store wins easily. Pick the layout that matches the workload β€” there's no universally best answer.

Recap Row storage keeps a record's fields together (great for OLTP β€” whole rows); columnar storage keeps a column's values together (great for OLAP β€” scan a few columns over billions of rows). Columnar reads only the columns a query needs, compresses brilliantly with RLE and dictionary encoding, and pairs naturally with vectorized execution and SIMD for big analytical speedups.

3 DuckDB internals β€” the "SQLite for analytics"


Explain like I'm 5

Most databases are like a restaurant: you (the app) phone in your order, a separate kitchen (the database server) cooks it, and a waiter carries the food back and forth over the network. DuckDB is like having a tiny chef living inside your own kitchen β€” no phone call, no waiter, no separate building. You hand it a question and it answers right there in your program. And this chef specialises in big number-crunching meals (analytics), fast.

DuckDB is an open-source analytical database often described as "SQLite for analytics." SQLite proved you don't always need a big client–server database β€” sometimes you want a fast database that just runs inside your application as a library. SQLite is row-oriented and aimed at transactional, embedded use. DuckDB takes the same embedded idea but builds it for analytics instead. Three design pillars define it:

πŸ”Œ
In-process
Runs as a library, no server
+
πŸ“Š
Columnar
Column-oriented storage
+
⚑
Vectorized
Batch (vector) execution engine

Pillar 1 β€” In-process (embedded)

DuckDB runs in the same process as your application β€” there is no separate server to install, configure, start, or connect to over a network. You just import duckdb (or link the C++ library) and go. Because there's no client–server boundary, query results don't have to be serialised and shipped over a socket β€” DuckDB can hand data straight to your program's memory. For data scientists this is huge: it reads directly from Pandas/Polars DataFrames, Parquet, and CSV files with almost zero copying.

Pillar 2 β€” Columnar storage

From Topic 2, you already know why this matters: analytical queries scan a few columns over many rows, and columnar layout reads only those columns and compresses them well. DuckDB stores data column-by-column (and reads Parquet, itself a columnar file format, natively).

Pillar 3 β€” Vectorized execution

Also from Topic 2: DuckDB processes data in vectors (batches of ~1–2 thousand values) rather than one row at a time, keeping the CPU cache hot and enabling SIMD. This is what makes its big GROUP BY and aggregation queries fly on a single laptop.

Why DuckDB is fast for analytics

It's not one trick β€” it's the combination: columnar storage means it reads only the needed columns (low I/O); compression means fewer bytes to move; vectorized execution means the CPU chews through those columns in tight, SIMD-friendly batches (low CPU overhead); and being in-process means no network or serialization tax. Each pillar removes a different bottleneck of an analytical scan.

Worked example β€” DuckDB querying a Parquet file directly
import duckdb

# No server, no setup, no loading step β€” query a file in place:
result = duckdb.sql("""
    SELECT region, SUM(amount) AS revenue
    FROM 'sales.parquet'      -- read the columnar file directly
    WHERE year = 2025
    GROUP BY region
    ORDER BY revenue DESC
""")
result.show()

# It scans ONLY the region, amount, and year columns (columnar),
# skipping every other column in the file, and processes them in
# vectorized batches β€” all inside your Python process.

Notice you query a file as if it were a table, with no import step. DuckDB pushes the column selection and the WHERE year = 2025 filter down into the Parquet reader, so it never even reads the columns or row-groups it doesn't need.

πŸ“ The written assignment β€” "analyze DuckDB vs PostgreSQL"

Your written assignment for this session is to analyze DuckDB vs PostgreSQL. The point isn't "which is better" β€” it's "which fits which job," and why, grounded in everything above. A good answer frames it as OLAP vs OLTP:

  • Architecture: PostgreSQL is a client–server system (a running server you connect to, supporting many concurrent users); DuckDB is in-process/embedded (a library inside one app).
  • Storage: PostgreSQL is row-oriented (great for fetching/updating individual rows); DuckDB is columnar (great for scanning a few columns over millions of rows).
  • Execution: PostgreSQL uses largely tuple-at-a-time execution tuned for OLTP; DuckDB uses vectorized execution tuned for OLAP scans.
  • Concurrency & writes: PostgreSQL handles many concurrent transactional writers with full MVCC (recall our transactions/isolation sessions); DuckDB is built mainly for read-heavy analytics, typically a single writer.
  • When to use each: PostgreSQL for the transactional system of record (orders, users, payments β€” many small reads/writes); DuckDB for fast local analytics over that data (dashboards, ad-hoc aggregation, data-science notebooks). They're complementary, not rivals β€” many teams use Postgres as the source of truth and DuckDB to crunch extracts of it.

Tip: run the same aggregation query on a large table in both and report the timings and why they differ β€” that turns the theory above into evidence.

Recap DuckDB is the "SQLite for analytics": an in-process (embedded, no server), columnar, vectorized database. Those three pillars each kill a different bottleneck of an analytical scan, which is why it's so fast on a single machine. For the assignment, contrast it with PostgreSQL as OLAP (DuckDB) vs OLTP (Postgres): columnar vs row, vectorized vs tuple-at-a-time, embedded analytics vs concurrent transactional server.

4 RocksDB & compaction


Explain like I'm 5

Back to your messy lecture notes. Over the day you fill up notepad after notepad. If you never tidy them, finding one fact means flipping through every pad β€” slow. So you have a cleanup rule. One option: every evening, merge all small pads into a few big sorted binders (tidy, but a lot of recopying). Another: just stack pads of similar size and only merge when you have a whole pile of them (less recopying, but messier to search). Compaction is that cleanup rule, and the two options are leveled vs tiered.

RocksDB is Facebook/Meta's high-performance embedded key-value store, built on the LSM tree from Topic 1 (it grew out of Google's LevelDB). It's a library embedded in your app β€” much like DuckDB is embedded, but RocksDB is a transactional key-value engine, not an analytics engine. It powers the storage layer of countless systems (Kafka Streams, CockroachDB, TiKV, MySQL's MyRocks, and more). The single most important thing to tune in an LSM engine is compaction, so let's go deep.

Why compaction exists

Every MemTable flush creates a new SSTable. Without cleanup you'd accumulate thousands of small, overlapping files β€” reads would have to check too many of them, and stale values/tombstones would waste space forever. Compaction merges SSTables into fewer, larger, sorted files and drops obsolete entries. The strategy for doing this is a fundamental trade-off.

Leveled compaction

Leveled compaction (LevelDB's and RocksDB's default) organises SSTables into levels L0, L1, L2, … Each level is roughly 10Γ— larger than the one above. Crucially, within each level (from L1 down) the key ranges of SSTables do not overlap β€” each level is effectively one sorted run split into files. When a level gets too big, some of its data is merged down into the next level.

  • Reads are good: below L0, a given key can be in at most one file per level, so a point lookup checks few files.
  • Space is good: aggressive merging keeps duplicates/tombstones from piling up, so wasted space is low.
  • Writes cost more: moving data down a level rewrites overlapping data repeatedly β†’ higher write amplification.

Tiered compaction

Tiered compaction (also called size-tiered, the default in Cassandra) instead lets several SSTables of similar size accumulate in a tier, and only merges them once you have enough β€” producing one bigger file that joins the next tier. It rewrites data far less often.

  • Writes are cheap: data is merged fewer times β†’ low write amplification.
  • Reads cost more: several overlapping files can hold the same key range, so a lookup may check more files β†’ higher read amplification.
  • Space costs more: multiple un-merged copies of overlapping data coexist β†’ higher space amplification.

The three amplifications β€” the core trade-off

You cannot optimize all three at once; this is the famous RUM trade-off of LSM tuning. Define them precisely:

TermDefinitionPlain English
Write amplificationBytes actually written to disk Γ· bytes of logical data written by the user.How many times your data gets re-written by compaction.
Read amplificationNumber of disk reads (files/blocks checked) per logical read.How many places you must look to answer one query.
Space amplificationBytes stored on disk Γ· bytes of live logical data.How much extra disk is wasted on stale copies & tombstones.
Worked example β€” same workload, two strategies

Suppose you ingest data and it eventually settles across several SSTables. Compare the two strategies on the three amplifications (lower is better):

# LEVELED compaction (RocksDB default)
write_amp  = HIGH    # data rewritten as it cascades L0β†’L1β†’L2…
read_amp   = LOW     # ≀1 file per level below L0 for a key
space_amp  = LOW     # duplicates aggressively merged away
# β†’ choose for READ-heavy & space-sensitive workloads

# TIERED (size-tiered) compaction (Cassandra default)
write_amp  = LOW     # data merged far fewer times
read_amp   = HIGH    # several overlapping files may hold the key
space_amp  = HIGH    # un-merged copies coexist
# β†’ choose for WRITE-heavy / high-ingest workloads

The rule of thumb: leveled trades write amplification for low read & space amplification; tiered trades read & space amplification for low write amplification. Pick based on whether your workload is read-heavy (leveled) or write-heavy (tiered).

Tuning knobs you'll actually meet

  • Compaction style β€” leveled vs tiered (RocksDB calls the latter "universal"), the biggest lever, set by your read/write ratio.
  • MemTable size & count β€” bigger MemTables = fewer, larger flushes = less compaction churn, at the cost of more RAM and longer recovery.
  • Level size multiplier (default ~10) and L0 file trigger β€” control how aggressively data cascades down.
  • Bloom filter bits per key β€” more bits = fewer wasted file reads (lower read amplification) for a little extra memory.
  • Block size & compression (e.g. LZ4, Zstd per level) β€” trade CPU for disk I/O and space.

Comparison of engines

Pulling this session together, here's how the engines we've discussed line up:

EngineCore structureLayoutDeploySweet spot
RocksDBLSM treeRow / key-valueEmbedded libraryWrite-heavy KV: state stores, metadata, high ingest.
CassandraLSM tree (tiered)Wide-column (row-ish)Distributed serverMassive write-heavy, horizontally scaled.
PostgreSQL / InnoDBB+ treeRowClient–serverOLTP system of record, mixed read/write.
DuckDBVectorized engineColumnarEmbedded libraryLocal OLAP analytics, data science.
ClickHouseMergeTree (LSM-like)ColumnarClient–serverLarge-scale OLAP at server scale.
There's no free lunch

Every choice here is a trade-off, not an upgrade. LSM buys fast writes by paying in read & write amplification. Columnar buys fast scans by making single-row OLTP awkward. Leveled compaction buys read performance by paying write amplification. Good database engineering is choosing which cost you can afford for your workload.

Recap RocksDB is an embedded LSM key-value engine, and compaction is its key tuning lever. Leveled compaction keeps non-overlapping levels (low read & space amplification, high write amplification β€” good for reads); tiered compaction merges similar-sized files less often (low write amplification, high read & space amplification β€” good for writes). You can't minimise write, read, and space amplification all at once β€” you tune the balance to your workload.

β˜… Putting it all together


Every topic this session was really one lesson seen from different angles: the right data structure depends entirely on the workload. Here's the one-paragraph story tying it together:

The big idea

Traditional databases use in-place B+ trees β€” read-optimized, great for OLTP. When you need to ingest writes blindingly fast, you flip the trade-off with an LSM tree: append to a MemTable, flush immutable SSTables, and clean up later via compaction β€” the design behind RocksDB and Cassandra, whose leveled vs tiered compaction lets you trade write / read / space amplification. When instead you need to scan a few columns over billions of rows (OLAP), you change the layout to columnar, which reads only the columns you touch, compresses them with RLE and dictionary encoding, and processes them with vectorized execution and SIMD. DuckDB packages exactly that β€” in-process, columnar, vectorized β€” into the "SQLite for analytics," the natural OLAP counterpart to a row-oriented OLTP database like PostgreSQL.

Quick self-check

Why are writes so fast in an LSM tree compared to a B+ tree?

LSM turns every write into a sequential append (to the WAL and the in-memory MemTable), which is much faster than a B+ tree's in-place, often-random page update. The cost is paid later, in the background, by compaction.

If SSTables are immutable, how does an LSM tree handle a delete?

It writes a tombstone β€” a marker saying the key is deleted. The old value still physically exists until compaction merges the files and finally drops both the old value and the tombstone.

Why does a query like SELECT SUM(amount) run faster on columnar storage?

Columnar storage packs each column's values together, so the engine reads only the amount column off disk instead of dragging in every other column of every row. Far less I/O, plus better compression and vectorized processing.

What do RLE and dictionary encoding do, and why does columnar help them?

RLE replaces runs of repeated values with (value, count); dictionary encoding replaces values with small integer codes from a lookup table. Columnar storage puts same-type, often-repetitive values next to each other, which is exactly the pattern these schemes compress best.

What are DuckDB's three design pillars, and why is each fast for analytics?

In-process (no network/server tax), columnar (reads only needed columns, compresses well), and vectorized execution (batch processing with SIMD, low per-row overhead). Together they remove the main bottlenecks of an analytical scan.

Compare leveled vs tiered compaction in terms of the three amplifications.

Leveled = low read & space amplification but high write amplification (good for read-heavy/space-sensitive). Tiered = low write amplification but high read & space amplification (good for write-heavy). You can't minimise all three at once β€” that's the RUM trade-off.

You're building the transactional store for an e-commerce checkout. Row or columnar? Why?

Row storage (e.g. PostgreSQL). Checkout is OLTP: many small reads/writes of whole records (one order, one user). Row layout keeps a record's fields together, which is ideal for fetching and updating individual rows.

πŸ“š References & Further Reading


Class material

Papers, docs & deep dives