Data warehouses and lakehouses
The database behind your app answers "show order 81" in a millisecond. "Revenue by country for the last 90 days" scans hundreds of millions of rows, and a different kind of store does it in seconds: a data warehouse. This guide explains why analytical stores are built the way they are, how BigQuery, Snowflake, Redshift, Databricks and the others differ, and which to choose, with a chooser and code you can run.
- Sam Why can't analytics just run on the app's database?
- Priya Because the questions are different. The app reads a few rows by key in a millisecond. "Revenue by country for ninety days" scans hundreds of millions of rows. A warehouse is built for that, and running it on the production database slows checkout.
- Sam What makes a warehouse different?
- Priya It stores columns together instead of rows, compressed, in large files, and spreads each query over many machines. Data arrives by batch loads, change data capture or streams. A lakehouse keeps the data as open Parquet files in object storage, with a table format like Iceberg or Delta adding transactions and time travel, so several engines can share it.
- Sam How does it run a query?
- Priya The planner reads table metadata and keeps only the files for the days asked for, and only the columns named. Workers scan those column chunks in parallel and sum their share. The partial results are combined. Reading one-and-a-half megabytes instead of nearly half a gigabyte is why it takes seconds.
- Sam What are the options?
- Priya BigQuery, serverless, paying per data scanned or for reserved slots. Snowflake, with independent compute clusters per team, on any cloud. Redshift on AWS, provisioned or serverless. Databricks SQL over Delta tables. Microsoft Fabric on Azure, which Microsoft positions as the path forward from Synapse dedicated pools. ClickHouse for real-time analytics on fresh data. And DuckDB, which runs inside your program.
- Sam How do I choose?
- Priya Internal BI on one cloud: that cloud's warehouse. Several clouds or strict isolation: Snowflake. Engineers and ML sharing the data: a lakehouse. Dashboards inside your product: ClickHouse, Pinot or Druid. And small data: DuckDB may be all you need.
- Priya Your turn. A daily report scans the whole history every time. The cheapest fix?
- Priya Partition the table by date, so only the days asked for are read.
- Sam And the code?
- Priya A three-million-row table read as rows, as two columns, and with partition pruning, plus run-length encoding of a sorted column.
- Sam Common mistakes?
- Priya Analytics on the primary database. SELECT star on a column store. No partitioning. A warehouse behind a customer-facing page. And thousands of tiny files.
1. OLTP and OLAP
| Operational database (OLTP) | Analytical store (OLAP) | |
|---|---|---|
| Typical query | Read or change a few rows by key | Scan and aggregate millions of rows, a few columns |
| Storage | Rows together (a row store), with B-tree indexes | Columns together (a column store), compressed, in large files |
| Scale | Many small concurrent transactions | Fewer, much bigger queries, spread over many machines |
| Examples | PostgreSQL, MySQL, DynamoDB (T03) | BigQuery, Snowflake, Redshift, ClickHouse |
Data reaches the warehouse by batch loads, change data capture or streams (T08), usually transformed with SQL (ELT, often with dbt). A lakehouse keeps the data as open files (Parquet) in object storage (T06), with a table format such as Apache Iceberg or Delta Lake adding transactions, schema changes and time travel, so several engines can query the same tables.
2. How a warehouse runs a query
3. The products
BigQuery
Storage and compute are separate: tables live in Google's columnar storage, and each query borrows slots (units of compute) from a large shared pool. Pay per data scanned on demand, or reserve slots. Partitioning and clustering cut what a query scans; BigLake and Iceberg tables extend it to open formats.
Best for: analytics on Google Cloud with nothing to size or run.
Snowflake
Separate storage and independent compute clusters (virtual warehouses) that start, stop and resize in seconds, so teams don't slow each other. Runs on AWS, Azure and Google Cloud, with data sharing between accounts and support for Iceberg tables.
Best for: a multi-cloud warehouse with strong isolation between workloads.
Amazon Redshift
A columnar, massively parallel warehouse, provisioned on RA3 nodes with managed storage, or Redshift Serverless. Queries data in S3 through Redshift Spectrum; Athena is AWS's separate serverless SQL engine for S3.
Best for: a warehouse inside an AWS estate.
Databricks SQL
SQL warehouses over Delta Lake tables in your own object storage, alongside Spark jobs, streaming and ML on the same data.
Best for: one platform for data engineering, ML and BI on open files.
Microsoft Fabric (and Azure Synapse)
Fabric combines a warehouse, lakehouse, pipelines and Power BI on OneLake, which stores tables in Delta format. Microsoft positions Fabric Warehouse as the path forward from Synapse dedicated SQL pools, which remain supported.
Best for: new analytics on Azure, especially with Power BI.
ClickHouse
A very fast columnar database for analytics on fresh data: inserts become queryable within seconds, and aggregations over billions of rows return in well under a second. Self-run or ClickHouse Cloud. Apache Druid and Apache Pinot fill the same niche.
Best for: user-facing dashboards and observability-style analytics.
DuckDB
An analytical database that runs inside your program, like SQLite for analytics, reading Parquet and CSV directly. Fast enough for tens or hundreds of gigabytes on one machine.
Best for: local analysis, notebooks, small pipelines and tests.
Apache Iceberg and Delta Lake
Metadata layers over Parquet files that add atomic commits, schema evolution, partition evolution and time travel. Most warehouses can now read and write at least one of them.
Best for: keeping data in open files that several engines share.
4. Differences at a glance
| Option | Compute model | You pay for | Fresh data | Open formats |
|---|---|---|---|---|
| BigQuery | Serverless slots | Bytes scanned, or reserved slots | Seconds with streaming inserts | BigLake, Iceberg |
| Snowflake | Virtual warehouses per team | Credits while warehouses run | Minutes; Snowpipe streaming faster | Iceberg tables |
| Redshift | Clusters or serverless | Nodes, or serverless capacity used | Minutes; streaming ingestion available | Spectrum over S3 |
| Databricks SQL | SQL warehouses | Compute used | Minutes; streaming tables | Delta (native), Iceberg |
| Fabric | Capacity units | Capacity | Minutes | Delta on OneLake |
| ClickHouse | Servers or cloud service | Servers or compute used | Seconds | Reads Parquet, Iceberg |
| DuckDB | Inside your process | Nothing (your machine) | Whatever you load | Parquet, Iceberg, Delta |
5. When to choose which, and why
- Never run heavy analytics on the production database. Copy the data out (CDC or batch) into an analytical store.
- Internal BI and reporting on one cloud? That cloud's warehouse: BigQuery, Redshift or Fabric. Snowflake when you're multi-cloud or want strict workload isolation.
- Data engineering and ML on the same data? A lakehouse: Databricks, or Iceberg tables shared by several engines.
- Dashboards inside your product, on data seconds old, with many users? ClickHouse, Druid or Pinot.
- Small data, one analyst? DuckDB on Parquet files may be all you need.
Try it: choose for your requirements
6. Why column stores scan less, in code
A 3-million-row orders table with 20 columns, and the query "total amount for one day". A row store reads every column of every row: 480 MB. A column store reads only the two columns the query uses: 48 MB. Partitioning by day means only one day's files are opened: 1.6 MB, 300 times less. And a sorted column compresses to almost nothing with run-length encoding. The program was run in all three languages; each prints the output below it.
DAYS, ROWS_PER_DAY, COLUMNS, BYTES = 30, 100_000, 20, 8 # an orders table: 3 million rows, 20 columns of 8 bytes
rows = DAYS * ROWS_PER_DAY
mb = lambda b: f"{b / 1_000_000:,.1f} MB"
# Query: SELECT SUM(amount) FROM orders WHERE day = '2026-11-03'
row_store = rows * COLUMNS * BYTES # whole rows are stored together, so every column is read
column_store = rows * 2 * BYTES # only the day and amount columns are read
partitioned = ROWS_PER_DAY * 2 * BYTES # partitioned by day: only one day's files are opened
print("row store, full scan: ", mb(row_store))
print("column store, 2 columns: ", mb(column_store))
print("plus partition pruning: ", mb(partitioned))
print(f"reduction: {row_store // partitioned}x less data read")
# Sorted columns compress well: run-length encoding stores each value once with a count.
day_column = [d for d in range(DAYS) for _ in range(ROWS_PER_DAY)]
runs = []
for d in day_column:
if runs and runs[-1][0] == d:
runs[-1][1] += 1
else:
runs.append([d, 1])
print(f"day column: {len(day_column):,} values -> {len(runs)} runs of (value, count)")
const DAYS = 30, ROWS_PER_DAY = 100_000, COLUMNS = 20, BYTES = 8; // an orders table: 3 million rows, 20 columns of 8 bytes
const rows = DAYS * ROWS_PER_DAY;
const mb = (b: number) => (b / 1_000_000).toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + " MB";
// Query: SELECT SUM(amount) FROM orders WHERE day = '2026-11-03'
const rowStore = rows * COLUMNS * BYTES; // whole rows are stored together, so every column is read
const columnStore = rows * 2 * BYTES; // only the day and amount columns are read
const partitioned = ROWS_PER_DAY * 2 * BYTES; // partitioned by day: only one day's files are opened
console.log("row store, full scan: ", mb(rowStore));
console.log("column store, 2 columns: ", mb(columnStore));
console.log("plus partition pruning: ", mb(partitioned));
console.log(`reduction: ${Math.floor(rowStore / partitioned)}x less data read`);
// Sorted columns compress well: run-length encoding stores each value once with a count.
const dayColumn: number[] = [];
for (let d = 0; d < DAYS; d++) for (let i = 0; i < ROWS_PER_DAY; i++) dayColumn.push(d);
const runs: [number, number][] = [];
for (const d of dayColumn) {
if (runs.length && runs[runs.length - 1][0] === d) runs[runs.length - 1][1]++;
else runs.push([d, 1]);
}
console.log(`day column: ${dayColumn.length.toLocaleString("en-US")} values -> ${runs.length} runs of (value, count)`);import java.util.*;
public class Main {
static final int DAYS = 30, ROWS_PER_DAY = 100_000, COLUMNS = 20, BYTES = 8; // an orders table: 3 million rows, 20 columns of 8 bytes
static String mb(long b) { return String.format(Locale.US, "%,.1f MB", b / 1_000_000.0); }
public static void main(String[] args) {
long rows = (long) DAYS * ROWS_PER_DAY;
// Query: SELECT SUM(amount) FROM orders WHERE day = '2026-11-03'
long rowStore = rows * COLUMNS * BYTES; // whole rows are stored together, so every column is read
long columnStore = rows * 2 * BYTES; // only the day and amount columns are read
long partitioned = (long) ROWS_PER_DAY * 2 * BYTES; // partitioned by day: only one day's files are opened
System.out.println("row store, full scan: " + mb(rowStore));
System.out.println("column store, 2 columns: " + mb(columnStore));
System.out.println("plus partition pruning: " + mb(partitioned));
System.out.println("reduction: " + rowStore / partitioned + "x less data read");
// Sorted columns compress well: run-length encoding stores each value once with a count.
int[] dayColumn = new int[DAYS * ROWS_PER_DAY];
for (int d = 0, i = 0; d < DAYS; d++) for (int r = 0; r < ROWS_PER_DAY; r++) dayColumn[i++] = d;
List<int[]> runs = new ArrayList<>();
for (int d : dayColumn) {
if (!runs.isEmpty() && runs.get(runs.size() - 1)[0] == d) runs.get(runs.size() - 1)[1]++;
else runs.add(new int[] {d, 1});
}
System.out.printf(Locale.US, "day column: %,d values -> %d runs of (value, count)%n", dayColumn.length, runs.size());
}
}row store, full scan: 480.0 MB
column store, 2 columns: 48.0 MB
plus partition pruning: 1.6 MB
reduction: 300x less data read
day column: 3,000,000 values -> 30 runs of (value, count)
7. Say it in the interview
- "Where do analytics run?" Not on the production database: change data capture streams rows into a warehouse such as BigQuery or Snowflake, partitioned by date. Analysts and dashboards query there, so their scans never slow checkout.
- "Warehouse or lakehouse?" A warehouse for SQL and BI with the least to manage; a lakehouse with Iceberg or Delta when several engines (Spark, ML jobs, the warehouse) must share the same tables in open files.
- "Live dashboards for customers?" A real-time analytical store such as ClickHouse or Pinot, fed from the event stream, because warehouses aren't built for thousands of small concurrent queries.
8. Common mistakes
SELECT * on a column storeIt reads every column and, on pay-per-scan pricing, bills for them. Name the columns.9. Check yourself
A query sums one column of a 20-column table. Roughly how much less does a column store read than a row store?
A daily report scans the whole orders history every run. The cheapest fix?
Your product shows each customer a live dashboard; thousands load it per minute. Which fits?
What does an open table format such as Iceberg add to Parquet files?
10. Sources and further reading
Reviewed on 4 November 2026. Pricing models and features change; check before relying on them.
| Source | Used for |
|---|---|
| BigQuery overview | Separate storage and compute, slots |
| Snowflake key concepts | Architecture and virtual warehouses |
| Apache Iceberg | Open table format features |
| Microsoft Fabric blog: a turning point for enterprise data warehousing | Fabric Warehouse and Synapse dedicated pools |
| Module 12: Indexes and storage engines | Row and column storage |