Data Engineering · lesson 7 of 23
Beginner
8- 1 Data Engineering Fundamentals
- 2 File Formats: CSV, JSON, Parquet
- 3 Idempotency and Safe Retries
- 4 Batch Pipelines and Time Windows
- 5 Partitioning and Physical Layout
- 6 Batch vs. Streaming: The Real Trade-off
- 7 Data Lake vs. Data Warehouse vs. Lakehouse
- 8 Data Modeling Basics: Star Schemas and Denormalization
Intermediate
4Advanced
11- 13 Streaming: Exactly-Once Semantics
- 14 Schema Evolution and Data Contracts
- 15 Streaming Architecture: Lambda and Kappa
- 16 Data Lineage: Tracing a Column Back to Its Source
- 17 Data Governance: Catalogs, Classification, and Access Policy
- 18 Data Security and PII: Hashing, Masking, and Tokenization
- 19 Pipeline Observability: Tracing, Anomaly Detection, and Alerting
- 20 Cost Optimization: Measuring What Partition and Column Pruning Actually Save
- 21 CI/CD for Data: Gating Promotion with Real Tests
- 22 Disaster Recovery: RPO, RTO, and What a Backup Actually Loses
- 23 Data Platform Architecture: Wiring the Pieces Together
Also part of Data Engineering
9- + Your First Airflow DAG from Airflow
- + Kafka Topics, Partitions, and Offsets from Kafka
- + Spark Architecture: Driver, Executors, and Cluster Managers from Apache Spark
- + Models, ref, and the DAG from dbt
- + Delta Lake: The Transaction Log from Databricks
- + The Medallion Architecture from Databricks
- + File I/O, pathlib, and JSON in Python from Python
- + Error Handling in Python from Python
- + Terraform Fundamentals (IaC) from Terraform
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
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.