Skip to main content
Data Engineering · lesson 7 of 23
Open the Data Engineering page →
Data Engineering beginner Lesson 7 of 23

Data Lake vs. Data Warehouse vs. Lakehouse

Load the same two files three ways to see schema-on-read, schema-on-write, and the transactional guarantees a lakehouse adds on top of lake-style storage.

By SkillByExample · Published

data-engineering data-lake data-warehouse lakehouse architecture

Load the same two files three ways — as a lake, a warehouse, and a lakehouse — and watch each one handle a schema change and a bad write differently.

Two files, one with an extra field

import duckdb, os, json

os.makedirs("lake", exist_ok=True)
with open("lake/day1.json", "w") as f:
    json.dump([{"id": 1, "name": "Ada", "amount": 25.5},
               {"id": 2, "name": "Grace", "amount": 12.0}], f)
with open("lake/day2.json", "w") as f:
    # the source team added a field mid-stream, with no warning
    json.dump([{"id": 3, "name": "Alan", "amount": 8.75, "currency": "GBP"}], f)

con = duckdb.connect()

Data lake: schema-on-read

rows = con.execute(
    "select * from read_json_auto('lake/*.json', union_by_name=true) order by id"
).df()
print(rows.to_string(index=False))
$ python lake_warehouse.py
== data lake: schema-on-read ==
 id  name  amount currency
  1   Ada   25.50      NaN
  2 Grace   12.00      NaN
  3  Alan    8.75      GBP

No table definition existed before this query ran — DuckDB inferred the union of both files’ shapes on the fly and filled the missing currency values with NaN. That is schema-on-read: maximally flexible, and every reader has to independently decide what a missing column means.

Data warehouse: schema-on-write

con.execute("create table warehouse_orders (id bigint, name varchar, amount decimal(10,2))")
con.execute("insert into warehouse_orders select id, name, amount from read_json_auto('lake/day1.json')")
try:
    con.execute("insert into warehouse_orders select * from read_json_auto('lake/day2.json')")
except Exception as e:
    print(f"insert of day2 into the fixed-schema table failed: {type(e).__name__}: {e}")
== data warehouse: schema-on-write ==
insert of day2 into the fixed-schema table failed: BinderException: Binder Error: table warehouse_orders has 3 columns but 4 values were supplied

The warehouse’s fixed, three-column schema rejects day2’s extra currency column outright — the opposite trade-off. Nothing ambiguous ever lands in the table, but loading it requires an explicit decision (add the column, or drop it) the moment the source shape changes.

Lakehouse: lake-style storage with transactions

con.execute("""create table lakehouse_orders
    (id bigint primary key, name varchar, amount decimal(10,2), currency varchar default 'USD')""")
con.execute("insert into lakehouse_orders select id, name, amount, 'USD' from read_json_auto('lake/day1.json')")
try:
    con.execute("begin transaction")
    con.execute("insert into lakehouse_orders select id, name, amount, currency from read_json_auto('lake/day2.json')")
    con.execute("insert into lakehouse_orders values (2, 'Duplicate Grace', 1.0, 'USD')")  # id 2 already exists
    con.execute("commit")
except Exception as e:
    con.execute("rollback")
    print(f"transaction rolled back: {type(e).__name__}: {e}")

count = con.execute("select count(*) from lakehouse_orders").fetchone()[0]
print(f"rows in lakehouse_orders after the failed transaction: {count}")
== lakehouse: table-format transactions over the same kind of files ==
transaction rolled back: ConstraintException: Constraint Error: Duplicate key "id: 2" violates primary key constraint.
rows in lakehouse_orders after the failed transaction: 2 (day2's valid row never landed either - the whole transaction is atomic)

The currency column exists here — it has a schema like a warehouse table — but day2’s valid row and the bad duplicate-key row were part of one transaction. Because the second statement failed, the whole transaction rolled back, including the first statement that would otherwise have succeeded on its own. That all-or-nothing guarantee across multiple writes is exactly what a plain data lake (raw files, no transaction log) does not give you, and it is the specific problem lakehouse table formats — Delta Lake, Iceberg, Hudi — were built to solve at the scale of many files across distributed storage, not a single local connection.

Practice

1. Add a third file, day3.json, with a completely different column set, and re-run the lake query.
 id  name  amount currency region
  1   Ada   25.50      NaN    NaN
  2 Grace   12.00      NaN    NaN
  3  Alan    8.75      GBP    NaN
  4 Isaac    5.00      NaN     EU

union_by_name=true keeps absorbing new columns with no upfront declaration — the flexibility that makes a lake a good landing zone for sources you don’t fully control yet.

2. Add the missing `currency` column to `warehouse_orders` and retry the day2 insert.
con.execute("alter table warehouse_orders add column currency varchar")
con.execute("insert into warehouse_orders select * from read_json_auto('lake/day2.json')")
print(con.execute("select count(*) from warehouse_orders").fetchone()[0])
3

Schema-on-write means the schema change has to be explicit and deliberate — there is no silent absorption of a new column the way the lake query did.

3. Commit a lakehouse transaction that only inserts valid rows, and confirm it does land.
con.execute("begin transaction")
con.execute("insert into lakehouse_orders select id, name, amount, currency from read_json_auto('lake/day2.json')")
con.execute("commit")
print(con.execute("select count(*) from lakehouse_orders").fetchone()[0])
3

Without the conflicting duplicate-key statement, the transaction commits cleanly — atomicity does not mean transactions always fail, only that a partial failure cannot leave partial data.

4. Query only the `id` and `amount` columns from the lake files. Does DuckDB still read the `name` field off disk?

For local JSON files, yes — the whole file is parsed either way; column pruning only pays off for columnar formats like Parquet, where unread columns are never even fetched from disk. See the file-formats lesson for that specific measurement.

Next: turning these raw, event-shaped rows into a queryable star schema.

Frequently Asked Questions

What is the actual difference between a data lake and a data warehouse?
A data lake stores files (Parquet, JSON, CSV) in their original shape and applies a schema only when a query reads them — schema-on-read. A data warehouse enforces a fixed schema at load time — schema-on-write. The lake is flexible but query-time surprises are possible; the warehouse is rigid but every row it holds is guaranteed to match the declared shape.
What problem does a lakehouse actually solve?
A plain data lake has no transactions — a crashed write can leave half-written files, and two concurrent writers can corrupt each other. A lakehouse (Delta Lake, Iceberg, Hudi) adds a transaction log on top of lake-style file storage, so a failed multi-file write rolls back completely instead of leaving a partial mess, while the underlying storage is still cheap object storage, not a proprietary warehouse.
Do I need Delta Lake or Iceberg to get lakehouse guarantees?
For a real production system, yes — this lesson uses DuckDB's own ACID transactions as a stand-in to demonstrate the atomicity guarantee concretely, but DuckDB is not a distributed lakehouse table format. See the databricks track's delta-lake and medallion-architecture lessons for the real implementation on cloud storage.
Which one should a new project pick?
A warehouse (or warehouse-shaped tables inside a lakehouse) for structured, query-heavy analytics where schema stability matters more than raw flexibility. A lake for landing raw data you are not sure how you'll query yet. A lakehouse when you want the lake's cheap storage and schema flexibility but cannot tolerate a corrupted partial write — which is most production pipelines today, hence its popularity.