01 · What Is ETL vs. ELT?¶
Every data pipeline moves data from a source to a place where it can be queried and trusted. The two dominant shapes for doing that are ETL (Extract, Transform, Load) and ELT (Extract, Load, Transform) — the letters are the same, the order is not, and that order changes almost everything about how the pipeline behaves.
What actually ran
The code in this lesson was run locally with the Python standard library
(sqlite3) plus pandas — no external services.
ETL: transform before it lands¶
In classic ETL, data is pulled from the source, cleaned and reshaped in flight (usually in the pipeline's own memory or a dedicated processing engine), and only the already-clean result is written to the destination.
import csv, io, sqlite3
raw_csv = """id,name,signup_date,plan
1, Ava Chen ,2026-01-15,pro
2,ravi kumar,2026-01-16,FREE
3,Ava Chen,2026-01-15,pro
"""
def extract(text):
return list(csv.DictReader(io.StringIO(text)))
def transform(rows):
seen = set()
clean = []
for r in rows:
key = (r["name"].strip().lower(), r["signup_date"])
if key in seen:
continue # drop duplicate before it ever reaches storage
seen.add(key)
clean.append({
"id": int(r["id"]),
"name": r["name"].strip().title(),
"signup_date": r["signup_date"],
"plan": r["plan"].strip().lower(),
})
return clean
def load(rows):
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (id INT, name TEXT, signup_date TEXT, plan TEXT)")
conn.executemany("INSERT INTO users VALUES (:id, :name, :signup_date, :plan)", rows)
return conn
rows = extract(raw_csv)
clean = transform(rows)
conn = load(clean)
print(f"Extracted {len(rows)} raw rows, loaded {len(clean)} clean rows")
for row in conn.execute("SELECT * FROM users"):
print(" ", row)
Extracted 3 raw rows, loaded 2 clean rows
(1, 'Ava Chen', '2026-01-15', 'pro')
(2, 'Ravi Kumar', '2026-01-16', 'free')
Notice the duplicate row (id 3, same name and signup date as id 1) never
makes it into users at all — ETL's transform step acts as a gatekeeper
before storage. The database only ever holds the clean shape.
ELT: land first, transform where it lives¶
ELT flips the order: raw data is extracted and loaded into the destination as-is, and transformation happens afterward, typically with SQL running inside the destination itself (a warehouse, lake engine, or database).
def load_raw(rows):
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users_raw (id TEXT, name TEXT, signup_date TEXT, plan TEXT)")
conn.executemany("INSERT INTO users_raw VALUES (:id, :name, :signup_date, :plan)", rows)
return conn
conn = load_raw(rows) # rows = the *raw* extract, untouched
print("Raw table (ELT lands everything, warts and all):")
for row in conn.execute("SELECT * FROM users_raw"):
print(" ", row)
# Transform happens afterward, as SQL against the landed data
conn.execute("""
CREATE TABLE users_clean AS
SELECT MIN(id) AS id,
TRIM(name) AS name,
signup_date,
LOWER(TRIM(plan)) AS plan
FROM users_raw
GROUP BY LOWER(TRIM(name)), signup_date
""")
print("Clean table, built with SQL against the already-landed raw table:")
for row in conn.execute("SELECT * FROM users_clean"):
print(" ", row)
Raw table (ELT lands everything, warts and all):
('1', ' Ava Chen ', '2026-01-15', 'pro')
('2', 'ravi kumar', '2026-01-16', 'FREE')
('3', 'Ava Chen', '2026-01-15', 'pro')
Clean table, built with SQL against the already-landed raw table:
(1, 'Ava Chen', '2026-01-15', 'pro')
(2, 'ravi kumar', '2026-01-16', 'free')
The raw, messy rows (including the duplicate) are all sitting in
users_raw — nothing was thrown away at ingestion time. The cleanup
happened afterward, as a second SQL step, and the original raw data is still
there if you ever need to recompute the transform differently.
Why the order matters¶
| ETL | ELT | |
|---|---|---|
| Where transform runs | In the pipeline (Python, Spark, a dedicated ETL tool) | In the destination (warehouse/lake SQL engine) |
| What lands in storage | Only the clean result | Raw data first, clean views/tables built on top |
| Reprocessing history | Requires re-running extraction against the source | Just re-run the transform SQL against already-landed raw data |
| Compute cost model | Pipeline infra pays for transform compute | Destination (warehouse/lake engine) pays for transform compute |
| Best fit | Sources that are hard to re-extract; strict pre-load validation | Cheap, elastic compute at the destination (cloud warehouses, lakehouses) |
ELT became dominant with cloud data warehouses and lakes precisely because storage got cheap and destination compute got elastic — landing raw data "just in case" stopped being wasteful. But ETL never disappeared: whenever a source can't be re-queried cheaply (a paid API with a strict rate limit, a production database you can only touch once a night), or when strict validation must happen before anything lands (financial records, PII redaction), transforming in flight is still the right call.
Traps¶
- Treating ELT as "no transformation needed." ELT still transforms — it just does so after loading, not before. Skipping the transform step entirely (shipping raw data straight to dashboards) is neither ETL nor ELT; it's just untrusted data with a warehouse bill attached.
- Assuming ELT is always cheaper. Landing raw data has a storage cost and a governance cost (more raw, unvalidated data sitting around, potentially containing PII you now have to secure). It shifts cost, it doesn't erase it.
- Forgetting the raw layer in ELT. The entire point of ELT is that raw
data survives landing so transforms are re-runnable. If you overwrite
users_rawon every run, you've lost that advantage and just built a slower, SQL-based ETL pipeline.
Cheat sheet¶
| Question | Points toward |
|---|---|
| Is the source expensive/rate-limited to re-query? | ETL |
| Do you need every raw byte preserved for audit or reprocessing? | ELT |
| Is destination compute cheap and elastic (cloud warehouse/lakehouse)? | ELT |
| Must invalid data be blocked before it's ever stored? | ETL |
| Is the transform simple enough to express in SQL? | ELT |
How It Actually Works¶
The ETL vs. ELT choice is really a choice about where the compute engine sits relative to the durable storage, and that placement changes the failure and cost profile more than any syntax difference does.
In classic ETL, the transform step runs in a separate compute tier (an ETL server, a Spark
cluster, a Python worker) that holds the entire in-flight batch in its own memory or local
disk before the load step ever touches the target. That intermediate state is invisible to
the target system and to anyone querying it — if the transform process crashes at row
800,000 of 1,000,000, there is no partial table to inspect; you re-run the extract from
scratch or from a checkpoint offset you tracked yourself. The target database only ever sees
finished, transformed rows arrive via INSERT/COPY, which is why ETL targets can enforce
strict schemas and constraints — nothing malformed ever reaches them.
ELT inverts this by loading raw bytes into the target first (a warehouse table, a lake object) and only then running transformation as SQL or a query-engine job executed inside the target's own compute. Mechanically this works because modern warehouses (Snowflake, BigQuery, Redshift, Databricks SQL) separate storage from compute: raw data lands as columnar files (Parquet/ORC-like internal formats) and a stateless query engine spins up elastic worker nodes on demand to scan and rewrite those files into new tables. The "transform" is compiled into a physical query plan — scan → filter → aggregate → shuffle → write — and executed with the same optimizer (cost-based statistics, predicate pushdown, partition pruning) used for ordinary analytical queries. This is why ELT scales transformation elastically (add more warehouse compute) while ETL scales it linearly with however many workers you provision on the ETL server, and why ELT keeps a durable, queryable copy of raw data at every stage even if a downstream transform step fails halfway through.
Exercise¶
Take the raw_csv above and add two more rows: one with a plan value of
"Enterprise " (mixed case, trailing space) and one that is an exact
duplicate of an existing row but with different casing in name. Run both
the ETL and ELT versions of the pipeline against the new data and compare:
which duplicate-detection rule (the Python seen set vs. the SQL GROUP BY)
catches the case-different duplicate, and does either miss a case the other
catches? Write down which one you would trust more in production and why.