Data Engineering For Ml

ETL vs ELT: What a Model Loses When the Raw Rows Are Thrown Away

0 of 31 complete

0%

Contents

Back|Data Engineering For MlETL vs ELT: What a Model Loses When the Raw Rows Are Thrown Away
1/31
85 min left
  1. Home
  2. AI Engineering: Data, RAG and Agents
  3. Data Engineering for ML
  4. ETL vs ELT: What a Model Loses When the Raw Rows Are Thrown Away
Prerequisites
Data Ingestion Pipelines: Getting Data Into the ML Platformrequired
Related Topics
Train/Serve Skew: One Input Computed Two WaysWhy Production BreaksFine-Tuning vs RAG vs Prompting: Choosing Your ApproachLLM and GenAI OpsParameter-Efficient Fine-Tuning: LoRA and QLoRALLM and GenAI OpsEvaluating LLMs in Production: Grading Answers That Have No Right AnswerLLM and GenAI OpsPrompt Management and Versioning: Treat Prompts as Production CodeLLM and GenAI Ops
1 of 31
Previous lesson
Data Ingestion Pipelines: Getting Data Into the ML Platform
Next lessonData Lakes, Warehouses, and Lakehouses for ML

System Design

  • Foundation
  • Intermediate
  • Advanced
  • Capstone

AI Engineering

  • Foundation
  • Data, RAG and Agents
  • Evaluation, LLM Ops and Security

systemdesign.academy

  • Home
  • Glossary
  • Interview prep
  • Reviews
  • About
  • Privacy
  • Terms

The Library That Sent Its Books Back

Imagine a small library that has run out of shelf space. The librarian has a plan. Every new book that arrives, she reads, writes a one-page summary for the card index, and sends the book back to the publisher. The index stays small and tidy. Anyone who asks "what is this book about?" gets a good answer in seconds.

Then, one day, a reader asks something new. "What does chapter seven say about the town's old railway?" The summary does not say. It was written for the questions people asked back then, and nobody asked about railways. The book is gone, so the question has no answer.

A second library keeps every book on the shelf and writes a summary only when a reader asks for one. Its shelves cost more. But when the railway question comes, the book is still there.

An illustration of a woman in a library aisle, holding a stack of books beside an empty book cart, looking thoughtful, next to text. Headed the library that sent its books back, titled keep only the summary, or keep the book too? Beside her: a librarian short of shelf space writes a one-page summary of each new book, then sends the book back. Beneath: the shop in this lesson did the same with its sales; a nightly job kept one row per customer per day, 38,502 rows, 1.1 MB. The shop's full record is 1,067,371 invoice lines, 94.8 MB; kept in cloud storage, they cost $0.00218 a month. Last: a summary is small and quick to read; a new question may need the book.

Many companies make this same choice with their data every night. A shop takes each day's sales and adds them up into a neat summary, one row for each customer for each day. That summary is what the finance team wants. The question is what happens to the sales lines that were added up.

In this lesson I measure that choice on a real online shop. Its summary has 38,502 rows, built from sales lines that the warehouse did not keep. The shop's full record is 1,067,371 lines. Keeping those lines in cloud storage would have cost $0.00218 a month. The rest of the lesson asks what the lines were worth when new questions came.

Where This Lesson Starts

This is the third lesson of the chapter on data engineering for machine learning. The first lesson asked how fresh each number needs to be. The second, data ingestion pipelines, covered how data gets copied out of the systems where it is made. This lesson asks what happens next: in what order the data is cleaned and stored, and what that order keeps.

Here is the plan. First I name the parts. Then I show the two orders side by side, where each one does its work, and what each one costs. Then comes a small lab on real data, from a real shop. After that, the ideas teams use to keep their stored data in order, and the cases where the old order is still right.

A flowchart headed the same invoice lines, two ways to keep them, titled what is kept decides what can be asked later. A box, every invoice line, as it happens, leads down two paths. On the left, the nightly job: one row per customer per day; the warehouse keeps only this, leading to a cylinder, the daily table: 38,502 rows. On the right, the other way: keep the lines as they are, leading to a cylinder, the raw lines: 1,067,371, then to a box, transform when a question comes. Both paths lead to a box, a model guesses who orders, or cancels. Beneath: in the lab, one model reads only the daily table; another reads the daily table plus features rebuilt from the raw lines.

The shop's data is public, so you can run every step yourself. The lab has one script, which builds the nightly summary and trains eight models: four designs, for each of two questions. A second script checks every number the first one printed, by a different method. Where I could measure something, I did. Where I could not, such as what a real cloud bill would be, I say so.

Ten Words for This Lesson

A hand-drawn list headed ten words for this lesson, titled where data goes, and in what order. Extract: copy data out of the system where it was made. Load: write it into the warehouse, or into cheap storage. Transform: clean, join and sum it into the shape a question needs. ETL: extract, transform, load; the warehouse keeps only the result. ELT: extract, load, transform; the raw data is kept as well. Warehouse: a database built to scan and sum very large tables. Raw lines: every invoice line, exactly as it arrived. Feature: one number a model reads about a customer. Cutoff: the moment a model looks from; it sees only the past. AUC: the chance a customer who did it is ranked above one who did not. Beneath: an AUC of 0.5 is a coin toss; an AUC of 1.0 is a perfect ranking.

Three steps move data from where it is made to where it is used. To extract is to copy the data out of the system that made it, such as a shop's app database. To load is to write it into the place where it will be kept and read. To transform is to clean it, join tables together and add rows up into the shape a question needs.

ETL means extract, transform, load, in that order: the data is cleaned and summed first, and the warehouse stores only the result. The raw rows live on only if the source database, or an archive, still keeps them, and a source database may delete old rows after a while. ELT means extract, load, transform: the raw data is stored first, as it arrived, and the transforms run later. A warehouse is a database built to scan and add up very large tables, such as Snowflake or Google's BigQuery.

A feature is one number a model reads about a customer, such as the days since their last order. A model learns from features. The cutoff is the moment a model looks from. It may use only what happened before the cutoff, and it guesses what happens after.

To score the guesses I use AUC, short for area under the curve. Take one customer who did the thing and one who did not. AUC is the chance the model gives the first a higher score. An AUC of 0.5 is a coin toss, and 1.0 is a perfect ranking.

ETL and ELT: Same Letters, Different Order

ETL and ELT use the same three steps. Only the place of the T changes, and that one change decides what data survives.

In ETL, the transform runs before anything is stored, usually on its own machines: an older ETL tool, a Spark cluster or a Python job. Only the finished table is loaded. The warehouse never sees the raw data. It survives only if the source database or an archive keeps it.

In ELT, the raw data is loaded first, unchanged. The transforms run later, inside the warehouse, as , the language databases use for questions. The raw data stays, so any table can be rebuilt from it, or a new table built.

A hand-drawn sketch with two lanes of three boxes, headed sketched with the lab's real sizes, titled same three letters; only the T moves. The ETL lane: E, copy out, 1,067,371 lines; then T, sum the 824,364 by day; then L, keep 38,502 rows; beneath it, the warehouse does not keep the lines. The ELT lane: E, copy out, 1,067,371 lines; then L, keep them, 94.8 MB; then T, any table, any time; beneath it, the lines stay, and the daily table is one query over them. Beneath the sketch: the ETL warehouse holds 1.1 MB; the lines live on only if the shop's own database or an archive still has them; ELT keeps 94.8 MB and can still build the same 1.1 MB table, or a different one.

These are the lab's real sizes. The shop has 1,067,371 invoice lines. The nightly job read the 824,364 that have a customer id and kept 38,502 rows, one for each customer on each day they had a line. 5,390 of those days hold only cancellations. As text files, the lines take 94.8 MB and the daily table 1.1 MB. An ELT pipeline keeps the 94.8 MB and can build the same 1.1 MB table from it whenever it likes.

Notice what does not change. Both orders extract from the same sources. Both end up with clean tables for dashboards and models. Nobody argues that the transform can be skipped. The argument is only about order. Order decides three things: what you pay to store, where the compute runs, and which questions can still be asked next year.

ETL and ELT, Side by Side

The order swap spreads into cost, into who runs which machine, and into what a future model can ask for. It helps to put the two side by side, row by row, because neither one wins every row.

A two-column table headed the two orders, side by side, titled what each one keeps, and what it costs. Left, ETL: order, transform then load; the transform runs on its own machines, before the warehouse; kept in the warehouse, only the result; a new question needs a new job, and the source may have deleted old rows; here, 1.1 MB, $0.00002 a month; a field that must not be stored readable is removed or masked before it lands. Right, ELT: order, load then transform; the transform runs inside the warehouse, as SQL; kept, the raw data and every result; a new question is a new query over data already there; here, 94.8 MB, $0.00218 a month; a field that must not be stored readable would land first, so mask it before load. Beneath: keeps less, pays when a question changes; keeps more, pays a little every month.

Read down a column to see one order as a whole. Read across a row to see the exact trade. Most rows favour ELT, and the storage row shows why the trade got easy: here, keeping everything cost two tenths of a cent a month more.

Look hard at the last row. It is the one place where ELT has no safe answer. Some fields must never be stored as they arrived. A card's security code must not be stored at all, and a card number may be stored only in a form nobody can read. For those fields the raw value must not land, so the transform has to come first. I come back to this near the end, because it is the clearest reason ETL is still used.

For machine learning, the row that matters most is the new question. With ETL, a feature the table never kept means a new job, and the history may already be gone. With ELT, the data is still there. The lab below measures how much that was worth, on real questions.

One Common Way to Build ELT

There are many ways to build an ELT pipeline. Here is one common shape, with real tools. The lab does all of it in a few lines of pandas, but the jobs are the same.

Six cards joined by down arrows, headed one common way to build ELT, titled copy raw, land raw, transform in SQL. PostgreSQL, with its logo: the shop's app database, where each order is written. Airbyte, or Fivetran, with the Airbyte logo: connectors that copy the rows out, unchanged. Apache Airflow, with its logo: schedules the copies and the transforms, and watches them. Snowflake, or BigQuery, with the Snowflake logo: the raw tables land here, and the SQL runs here. dbt, with its logo: each transform is a SQL file in Git, run in dependency order. Feature tables: one row per customer, what training reads. Beneath: real tools that teams use for this, not the lab's; the lab does every step in pandas; Fivetran has no logo here because the logo set has none.

Each tool has one job. A connector such as Airbyte or Fivetran copies rows out of a source into the warehouse without changing them. Airbyte's connectors are open source; Fivetran is a commercial service. Airflow does not copy anything itself. It is a scheduler: in its own words, "a platform to programmatically author, schedule and monitor workflows".

dbt holds each transform as a file in Git, a system that keeps every version of a file, and runs the files in the right order. dbt calls each of these SQL files a model. That is a different thing from a machine learning model: a dbt model is one SQL query that builds one table. In this lesson, "dbt model" always means the SQL kind.

StageWhat happensTools
Copy outRows are copied from each source, unchangedAirbyte, Fivetran

Where the Transform Runs

Here is the idea that makes ELT cheap to run: send the transform to the data, instead of pulling the data out to the transform.

In ETL, the data is pulled out to a separate cluster, a group of machines you run and pay for. Then the finished table is written into the warehouse. In ELT, dbt sends only the text to the warehouse. The warehouse runs it and writes the new table. No row comes back to dbt, and no second cluster runs.

A sequence diagram with three columns, dbt, compute and storage, headed one dbt run, inside a warehouse, titled the SQL travels; the rows stay in the warehouse. Step 1, dbt sends compute the SQL, as text. Step 2, compute asks storage to read the raw lines. Step 3, storage sends back the rows it needs. Step 4, compute joins and sums. Step 5, compute writes the new table to storage. Step 6, compute tells dbt it is done, with a row count. Beneath: no row goes back to dbt, and no second cluster runs; but compute and storage are separate parts of the warehouse, and Snowflake and BigQuery both say so in their documentation.

The old version of this lesson said the transform ran "on the same machines that store the data" and that "nothing moves across the network". For Snowflake and BigQuery that is wrong, and I have fixed it. Both keep storage and compute apart on purpose. Snowflake's documentation says it "separates storage and compute" and that "each virtual warehouse is an independent compute cluster". BigQuery's says "one of the key features of BigQuery's architecture is the separation of storage and compute".

So data does move, inside the warehouse, from its storage to its compute. What ELT removes is the trip out and back, and the second system to run. That split is also why compute can grow for one big job and then stop.

What Keeping the Raw Data Costs

The old version of this lesson had a chart of monthly cost against data size, with ETL rising to $11,500 a month and ELT to $2,900. No calculation or source stood behind either line, so I have taken it out. Here is what I could measure, with a real price.

An isometric drawing of two blocks, heights to scale, headed elt-demo.json: the two tables as CSV, titled keeping every line costs a fifth of a cent a month. A tall block on the left: 94.8 MB, the raw lines, $0.00218 a month. A block so flat it is almost a tile on the right: 1.1 MB, the daily table, $0.00002 a month. Beneath: at the S3 Standard price, $0.023 a GB a month for the first 50 TB; the raw lines are 89 times bigger than the daily table; what the ETL job saves here is about two tenths of a cent a month.

I wrote both tables out as CSV, plain text with commas between the values, and measured the bytes. The raw lines take 94.8 MB. The daily table takes 1.1 MB, 89 times less. Amazon S3, a cloud file store, lists $0.023 per GB a month for its Standard storage, the first 50 TB, in its US East region. At that price the raw lines cost $0.00218 a month and the daily table $0.00002.

This shop is small, so these are tiny numbers. The cost grows in step with size: a thousand times the data costs about a thousand times as much, around $2.18 a month. What matters is the shape. Storage is billed by the byte and by the month, and a byte costs very little.

Compute is billed differently. Snowflake bills a warehouse "per-second, with a 60-second (i.e. 1-minute) minimum", and can switch it off when idle. BigQuery's on-demand queries cost $6.25 per TiB of data read in its default US region, with the first TiB each month free. Either way you pay while a transform runs, not while it waits. I did not run any of this on a cloud, so these are list prices, not a bill.

How Fresh Can a Table Be?

Loading raw data first does not, by itself, make a table fresh. It makes freshness a choice. The raw rows are already in the warehouse, so how often you rebuild a table is up to you, and up to your bill.

A vertical time scale with rough marks, headed how often a table can be rebuilt; a log scale, so equal steps are equal ratios, titled loading raw lets you choose; it does not make a table fresh. Down the left: 1 s, 1 min, 1 h and 1 day. At the top, a box spanning the seconds: seconds, a stream, as in lesson 1. Marks point at the scale: at 1 minute, Snowflake dynamic table, 60 s at the least, and Fivetran Enterprise, 1 min; at 15 minutes, Fivetran Standard, 15 min; at 6 hours, Fivetran's default, every 6 h; at the bottom, a nightly job, like the lab's, every 24 h. Beneath: with the raw rows already in the warehouse, how often to rebuild a table is your choice and your bill; fresher than a minute needs a stream, or a warehouse feature that works like one, such as BigQuery's continuous queries.

The scale is a log scale, so each labelled step is many times longer than the one above it. A minute is 60 seconds, an hour is 60 minutes, and a day is 24 hours. The vendor marks come from each vendor's own pages; the last mark is simply a nightly job, like the lab's. Fivetran's pricing page lists "15-minute syncs" on its Standard plan and "1-minute syncs" on Enterprise. Its documentation lists settings from 1 minute to 24 hours, with 6 hours as the default. Snowflake's dynamic tables, which refresh themselves to stay within a lag you set, cannot be set below 60 seconds.

The old lesson said you could "wire in change data capture and incremental models for seconds-behind freshness". Change data capture copies each change from a source database as it happens. An incremental model adds only the new rows to a table instead of rebuilding it.

I found nothing to support seconds-behind freshness for dbt and most warehouse transforms, which run on a schedule, and I have cut the claim. One exception I found is BigQuery's continuous queries, which its documentation calls " statements that run continuously". For a number that must be seconds old, you need a stream: a program that never stops and handles each record as it arrives. The first lesson of this chapter measured when that is worth paying for.

Bronze, Silver, Gold

Once raw data is kept and transformed in place, it needs some order, or it turns into a pile nobody trusts. One pattern for this, which Databricks describes, is the medallion pattern, with three layers named bronze, silver and gold. Databricks also calls it a "multi-hop" design, because the data hops from one layer to the next.

A hand-drawn stack of three boxes joined by up arrows, headed the shop's own tables, as three layers, titled bronze, silver, gold, with the lab's real counts. At the bottom, bronze: as it arrived; changed only for legal deletions; every line, 1,067,371, 94.8 MB. In the middle, silver: cleaned; the lines with a customer id, 824,364. At the top, gold: what a model reads; the daily table, 38,502 rows; the test features, 3,223 customers x 15. Beneath the stack: 243,007 lines have no customer id; bronze keeps them; silver leaves them out. Beneath the sketch: a bug in silver or gold is fixed by changing the SQL and running it again on bronze; nothing is copied out of the shop's systems a second time.

Bronze holds the data exactly as it arrived. In Databricks' words, its tables match the source "'as-is'", plus columns that "capture the load date/time, process ID, etc." Nothing is cleaned and nothing is dropped. Here that is all 1,067,371 lines.

Silver is bronze after cleaning: "matched, merged, conformed and cleansed", which means joined and made consistent. Here the only cleaning is to set aside the 243,007 lines that have no customer id, since no per-customer table can use them. That leaves 824,364 lines.

Gold is the shape a dashboard or a model reads. Here there are two gold tables. One is the daily table, 38,502 rows. The other is the features for the test customers, 3,223 rows of 15 numbers.

Each layer is a set of files that read the layer below. The payoff is repair. If a gold table has a bug, you fix its SQL and run it again on silver and bronze, which a bug fix never touches. Nothing is copied out of the shop's systems a second time. That also means a training table from months ago can be rebuilt, as long as its SQL is pinned to a date, which a later slide covers.

There is one exception to "bronze never changes", and it comes from the law. GDPR gives people a "right to erasure": a company must erase their personal data "without undue delay" when certain grounds apply. It also says personal data may be kept "for no longer than is necessary". So bronze is never edited to fix a bug, but it must allow the deletions the law requires. Raw personal data also needs a set retention period, a rule for how long it is kept before it is deleted.

What the Lab Ran

I wrote the lab's design into the docstring of its script, etl_elt_demo.py, on 30 September 2026, before it ever ran. The script is the same file you can copy from this lesson and run yourself.

An editorial page in four labelled zones, headed what the lab ran: etl_elt_demo.py, designed before it ran, titled one shop, one nightly job, two questions. The data: UCI Online Retail II, every invoice line of a UK online gift shop, 2009-12-01 to 2011-12-09; 1,067,371 lines; 243,007 have no customer id and are left out of both designs. The ETL job: from the 824,364 lines with a customer id, one row per customer per day, net revenue, where a cancellation counts as minus, and the number of invoices; 38,502 rows; 5,390 are days with only cancellations; the warehouse keeps nothing else. The questions: for each customer who bought in the 270 days before a cutoff, in the 90 days after it, do they order anything? Do they cancel or return anything? The split: learned at 2010-09-10 from 3,238 customers; tested at 2011-09-10 on 3,223; HistGradientBoostingClassifier, seed 0, one run. Beneath: the bootstrap was declared first; the shuffles, the Christmas question and the sketched customer came after the results.

The data is UCI Online Retail II: every invoice line of a UK online shop that sells gifts, from 1 December 2009 to 9 December 2011. Its description says many of the shop's customers are wholesalers, businesses that buy to sell on. Each line is one product on one invoice, with the quantity, the price, the time, the customer id and the country. An invoice number that starts with C is a cancellation. There are 1,067,371 lines. Of these, 243,007 have no customer id, so neither design can use them.

The nightly job is the ETL job. It reads the 824,364 lines that have a customer id, and turns each day's lines into one row per customer per day with two numbers. One is the net revenue: quantity times price, added up, where a cancelled line counts as minus. The other is the number of invoices, orders and cancellations together. The warehouse keeps nothing else. That gives 38,502 rows, and 5,390 of them are days on which the customer only cancelled.

The questions. At a cutoff, take every customer who bought something in the 270 days before it. Then ask two questions about the 90 days after it. Do they order anything? That is buys. Do they cancel or return anything? That is cancels.

The split. The models learn at the cutoff 10 September 2010, from 3,238 customers. They are tested a year later, at the cutoff 10 September 2011, on 3,223 customers. So every test answer is in the future of everything the models learned from.

The Main Run

Here is what the demo stored in results/elt-demo.json. Besides AUC, I report a second score that is easier to picture. Take the tenth of test customers the model was surest about. Top 10% is the share of them who really did the thing.

A table headed the main run, elt-demo.json, titled close on both questions; the raw lines added a little AUC. Columns: design, buys AUC, top 10%, cancels AUC, top 10%. Recency: 0.648, 0.709, 0.651, 0.322. Etl: 0.712, 0.960, 0.728, 0.474. Elt: 0.725, 0.944, 0.736, 0.495. Raw90: 0.720, 0.941, 0.727, 0.486. Beneath: share who did it, buys 0.578, cancels 0.177; 3,223 test customers, one run each; top 10%, the share who did it among the tenth the model was surest about.

Of the 3,223 test customers, 0.578 ordered again in the 90 days, and 0.177 cancelled something. Days since the last purchase alone gave an AUC of 0.648 for buys and 0.651 for cancels. The eight daily-table features gave 0.712 and 0.728.

Adding the seven raw features gave 0.725 and 0.736. From the unrounded AUCs, those gains are 0.014 for buys and 0.008 for cancels. Both are small. And on the top-10% score for buys, the daily table did better: 0.960 of its surest tenth ordered again, against 0.944 with the raw features.

A bar chart headed elt-demo.json: AUC on the 3,223 test customers, titled each design's AUC, for both questions. Two groups of four bars, buys and cancels, on a scale from 0.50 to 0.80 labelled AUC, 0.5 is a coin toss, one bar each for recency, etl, elt and raw90. For buys the bars stand near 0.65, 0.71, 0.73 and 0.72; for cancels near 0.65, 0.73, 0.74 and 0.73. Beneath: buys, 0.648, 0.712, 0.725, 0.720; cancels, 0.651, 0.728, 0.736, 0.727, in the order recency, etl, elt, raw90; one run each.

Raw90 landed between the two: 0.720 for buys and 0.727 for cancels. It kept most of the small buys gain and none of the cancels gain. Part of the reason is simple. 1,285 of the 3,223 test customers had no lines at all in the last 90 days before the cutoff, so their seven raw features were blank.

This is not the result the old version of this lesson promised. It said the raw column is the difference between "shipping a model this week and shipping it next quarter". On these two questions, the daily table already held most of what the models could use. The next slides ask whether the small gains are real, where the raw lines did matter, and why.

Could the Gap Be Luck?

A gap of 0.014 on 3,223 customers could come from luck: from which customers happened to be in the test. The demo's design, written before the run, declared one check for this, a bootstrap.

A bootstrap asks: if I had drawn a slightly different test group, how much would the gap move? It picks 3,223 customers from the test group at random, with repeats allowed, and measures the gap on them. Then it does that 1,000 times. The spread of those 1,000 gaps shows how much the gap moves by luck.

Two panels headed elt minus etl, 1,000 bootstrap draws, declared before the run, titled one gain held up; the other could be luck. Buys: +0.014; AUC; the middle 95% of draws, +0.002 to +0.025; elt ahead in 99%. Cancels: +0.008; AUC; the middle 95%, -0.008 to +0.025; elt ahead in 84%. Beneath: each draw picks 3,223 test customers again, with repeats, and scores both designs on the same picks.

For buys, the middle 95% of the 1,000 gaps ran from +0.002 to +0.025, and elt came out ahead in 99% of them. The gain is small, but here it held up.

For cancels, the middle 95% ran from -0.008 to +0.025. That range includes zero, and elt was ahead in only 84% of draws. So the cancels gain cannot be told apart from luck on this data.

A bootstrap only checks this one question: would a different set of test customers from the same shop and the same year give the same answer? It says nothing about another shop, another year or another model. And one run of one model is one data point.

What the Nightly Job Hid

If the raw lines added so little for cancels, did the daily table see cancellations after all? Partly. When a cancellation outweighs a day's orders, that day's net revenue goes below zero. A negative day is the one sign in the daily table that can only mean a cancellation. The table also counts cancellation invoices in its invoices column, but mixed in with orders. So I counted how many cancellers left a negative day.

Two panels headed the test customers, in the 270 days before the cutoff, titled what the daily table shows of a cancellation. Cancelled something: 1,107; customers, as the raw lines show them. A day of negative revenue: 930; of them, the one sign in the daily table that can only mean a cancellation. Beneath: 177 customers cancelled but never had a negative day; each day they cancelled, their orders that day were worth at least as much; their cancellation invoices still count in the invoices column, mixed in with orders.

In the 270 days before the test cutoff, 1,107 test customers cancelled at least one line. 930 of them had at least one day of negative revenue in the daily table. That leaves 177 who cancelled but never had a negative day. On each day they cancelled, their orders that same day were worth as much or more, so the net never went below zero. Their cancellation invoices are still in the invoices column, but a reader of the table cannot tell them from orders.

Netting means adding the pluses and the minuses into one total. Here it hid the cancellations of about one canceller in six, and showed the rest only roughly. It could not show how many lines, or what share of invoices, each one cancelled.

After the results, I picked one customer to look at closely, by a rule fixed before looking at anyone. The rule: the test customer with the lowest id who cancelled at least one line and had no day of negative revenue. That was customer 12352, on Tuesday 1 March 2011.

A hand-drawn table of eleven invoice lines, headed one real customer, picked by a rule fixed first: 12352, Tuesday 2011-03-01, titled eleven lines went in; one ordinary row came out. Columns: time, invoice, product, units x price. At 14:57, invoice 545323: 22138, 3 x 4.95; 22654, 6 x 5.95; 22844, 4 x 8.50; 84050, 12 x 1.65; POST, 1 x 40.00. Marked as cancellations: at 15:47, invoice C545329, M, manual, -1 x 280.05 and -1 x 183.75; at 15:49, invoice C545330, M, manual, -1 x 376.50. At 15:52, invoice 545332, M, manual, 1 x 376.50, 1 x 280.05, 1 x 183.75. An arrow leads to one row: the daily table, 12352, 2011-03-01, revenue 144.35, 4 invoices. Beneath: three manual charges were cancelled, then billed again, so they net to zero; over the 270 days, 10 cancelled lines, 0 days of negative revenue.

Which Dropped Column Mattered

Which of the seven raw features did the elt model actually use? After the results, I tested each one by shuffling it. Shuffling a feature mixes its values up across the test customers, so it no longer matches the right person, while its values stay realistic. If the AUC then drops, the model was leaning on that feature.

One shuffle is one random draw, so I shuffled each feature 30 times, with seeds 0 to 29, and kept every result. My first version of this slide used a single shuffle. A review of the lesson ran 30 and showed that a single shuffle can land far from the typical result. So the report now runs 30, and the numbers below come from those.

A bar chart headed each raw feature shuffled 30 times, elt model, designed after the results, titled which dropped column the model leaned on. For each of seven raw features, cancelled, cancel %, products, per order, units, price and UK, two bars show the mean change in AUC over 30 shuffles, one for buys and one for cancels, on a scale from -0.024 to +0.009, with a small dot over each bar for each of the 30 shuffles. The two lowest bars are buys for products, near -0.014, its dots from about -0.020 to -0.008, and cancels for cancel %, near -0.013, its dots from about -0.022 to -0.004. The cancels bar for products stands above zero, near +0.003, and all its dots are above zero. The buys bar for cancelled sits just above zero, with dots on both sides. The bars for UK sit at zero. Beneath: below zero, the model leaned on it; cancels leaned most on cancel %, the share of invoices that cancel, mean -0.013; buys on products, the distinct products bought, -0.014; for cancels, shuffling products raised AUC in 30 of 30 shuffles, mean +0.003.

For cancels, the model leaned most on the share of invoices that cancel. Shuffling it lowered the AUC by 0.013 on average, and by between 0.004 and 0.022 in every one of the 30 shuffles. The number of cancelled lines came next, 0.006 on average, also below zero every time. Both features need the raw lines. They are exactly the detail the netting removed.

For buys, the model leaned on the number of different products a customer bought: 0.014 on average, between 0.008 and 0.020, below zero in all 30. Wholesalers tend to buy many products, and this shop sells to many wholesalers, but that is one possible reading, which I did not test. The other raw features moved the buys AUC by 0.003 or less on average, with shuffles on both sides of zero.

One result points the other way, and it is not noise. For cancels, shuffling the number of products raised the AUC in all 30 shuffles, by 0.003 on average and at least 0.001. So the link between products and cancelling that the model learned from the 2010 customers made its 2011 guesses slightly worse. One possible reason, which I did not test, is that this link changed between the two years.

A Question the Table Never Recorded

Buys and cancels are both close to what the daily table holds: money, days and invoices. The real risk of ETL is a question about something the table never recorded at all. So, after seeing the results, I added one such question. I am saying plainly that I chose it knowing the first results were small.

The question: in the 90 days after the cutoff, will the customer order a product whose description contains the word CHRISTMAS? The daily table has no products in it at all.

A bar chart headed a question the daily table never recorded, designed after the results, titled will they order a Christmas item in the next 90 days? Three pairs of bars, AUC and top 10%, for etl, elt and elt+, on a scale from 0 to 1 labelled score. The AUC bars stand near 0.66, 0.71 and 0.72; the top 10% bars near 0.71, 0.82 and 0.81. Beneath: AUC 0.658, 0.713, 0.717; top 10% 0.706, 0.824, 0.811; elt minus etl, +0.055, middle 95% of 1,000 draws +0.039 to +0.072; elt+ adds one feature, also after the results, +0.059.

Of the test customers, 0.411 ordered a Christmas item in the 90 days. The daily-table model scored an AUC of 0.658. The elt model, with the same seven raw features as before, scored 0.713, a gain of 0.055. Its surest tenth went from 0.706 who did it to 0.824. Both of those designs were fixed before the first run; only the question came after.

Then I added one feature built for this question: the share of a customer's past lines that were Christmas items. That design, elt+, scored 0.717, only a little more. One possible reason: the 270 days before a September cutoff run from mid-December to early September, so few past lines were Christmas items at all. On average they were 1.2% of a customer's lines.

I ran the same bootstrap as before, 1,000 draws. For elt against etl, the gap of +0.055 had a middle 95% from +0.039 to +0.072. For elt+ against etl, +0.059, from +0.042 to +0.075. So on this question the raw lines added far more than on buys or cancels, and the gain did not look like luck. Keep the order of events in mind, though. I picked this question after seeing that the first two gains were small, and I added the elt+ feature after that.

A Feature Table in dbt, Pinned to a Date

Here is what a transform looks like in an ELT stack. This dbt model builds some of the lab's features in , inside the warehouse. It reads the silver lines and the old daily table, which in ELT is simply one more model.

It is written for Snowflake, from Snowflake's documentation. I have not run it: I have no Snowflake account, and the lab does the same work in pandas.

-- models/gold/customer_features.sql
-- One row per customer, as of a fixed date. Written for Snowflake.
-- Run with: dbt build --vars '{"as_of": "2011-09-10"}'

{{ config(materialized='table', tags=['ml', 'gold']) }}

with lines as (
    select * from {{ ref('stg_lines') }}          -- typed lines with a customer id
    where invoice_day <  '{{ var("as_of") }}'::date
      and invoice_day >= dateadd(day, -270, '{{ var("as_of") }}'::date)
),

days as (
    select * from {{ ref('int_customer_days') }}  -- the old daily table, now a model
    where day <  '{{ var("as_of") }}'::date
      and day >= dateadd(day, -270, '{{ var("as_of") }}'::date)
),

from_days as (
    select
        customer_id,
        datediff(day, max(case when revenue > 0 then day end),
                 '{{ var("as_of") }}'::date)               as recency,
        sum(case when revenue > 0 then 1 else 0 end)       as buy_days,
        sum(revenue)                                       as revenue,
        sum(case when revenue < 0 then 1 else 0 end)       as neg_days
    from days
    group by customer_id
),

from_lines as (
    select
        customer_id,
        sum(case when is_cancel then 1 else 0 end)         as cancel_lines,
        count(distinct case when is_cancel then invoice end)
          / count(distinct invoice)                        as cancel_share,
        count(distinct case when not is_cancel then stock_code end) as products,
        median(case when not is_cancel then unit_price end) as median_price
    from lines
    group by customer_id
)

select d.*, l.cancel_lines, l.cancel_share, l.products, l.median_price
from from_days d
left join from_lines l using (customer_id)
where d.buy_days > 0

Two kinds of text in the model belong to dbt, not to SQL. The parts inside {{ }} are filled in by dbt before the query is sent to the warehouse. ref('stg_lines') becomes the real name of that table, and var('as_of') becomes the date passed in. The table names follow the habit in dbt's own guide to structuring a project. stg_ marks a model, which brings in one source table and tidies it; the guide says each source table gets a single staging model. marks an model, a step between staging and the final tables.

Common Mistakes When Teams Move to ELT

ELT removes some old problems and brings some new ones. These are the ones I would check first on any team making the move. They are general lessons from practice, not numbers measured in this lab.

Editing bronze. The whole value of the raw layer is that it never changes. If someone "fixes" a bad row directly in bronze, old tables can no longer be rebuilt the way they were. Fix it in a silver or gold model, and leave bronze as it arrived. The one exception is a deletion the law requires, such as a GDPR erasure request.

Loading a field that must never be stored. "Load everything" is a default, not a rule that beats a law or a card network's rules. A card's security code must not be kept after the payment is approved. A card number may be kept only in a form nobody can read. To mask a value is to hide part of it, for example all but its last four digits. To tokenize it is to swap it for a stand-in code, a token, that only a separate, locked system can turn back into the real value. Remove, mask or tokenize fields like that before they land.

Transforms that read today's date. A model that uses current_date gives a different answer every day. Pin every transform to an as-of date.

A made-up value that looks real. A common way to fill a gap is to give a customer with no orders a days_since_last_order of 999. A model sees 999 as a real, very large number and learns from it as if it were one. A separate has_ordered flag says what you mean.

A null that quietly becomes an answer. If the gap is not filled, the value is null, which means unknown. In , a null compared with 90 is not true. So case when days_since_last_order > 90 then 1 else 0 end gives 0 for a customer who never ordered, with no error. Decide what a null means before any comparison.

Rebuilding everything, forever. A materialized='table' model is, in dbt's words, "rebuilt as a table on each run". That is fine while tables are small. As history grows, models help: they "insert or update records into a table since the last time that model was run".

Lineage: The Graph dbt Draws

Each dbt model names the models it reads, with ref(). From those names dbt builds the full graph of which table depends on which, and runs the models in order. That graph is called lineage: where each table came from.

A flowchart headed a sketch: the lab's tables as dbt models, titled in ELT the old ETL table is one model among several. A cylinder, raw.invoice_lines, as they arrived, leads to stg_lines: typed; lines with a customer id. That leads to two boxes: int_customer_days, the old daily table, which leads to the 8 features from the days; and the 7 features from the lines. Both feature boxes lead to the training table. Beneath: dbt draws this graph from each model's ref() calls; dbt build --select int_customer_days+ reruns that model and everything after it, with their tests.

This is a sketch of how the lab's tables would sit in a dbt project. The names are mine, not a real project's. Look at where the old nightly job went. In ELT it is int_customer_days, one model among several, and the seven raw features are a second branch beside it.

The graph gives three things. First, reruns: fix a bug in int_customer_days, and dbt build --select int_customer_days+ reruns it and everything after it. dbt does not do this by itself; the + asks for it. Second, tests: dbt build runs models and tests together, and in dbt's words, "a test failure will cause those downstream resources to skip entirely". Plain dbt run runs no tests. Third, documents: dbt docs generate builds a site with the graph and the descriptions you write for each model and column.

The old version of this lesson said this lineage traces "any feature back to the exact raw source it came from". That made it sound as if every column were traced for free. dbt Core draws lineage between models, not between columns. In dbt's own products, column-level lineage needs its paid Catalog, on its Enterprise plans, or its newer v2 edition, which is not fully open source.

When ETL Still Wins

ELT is a common default, not a law. There are real cases where transforming first is right. The question is best asked for each field, not for the whole company.

A flowchart headed per field, not per company, titled three questions before you load a field raw. The first question, must it never be stored raw? Yes: remove or mask it before load, ETL for that field. No: is the work heavy, and not SQL? Yes: run it on Spark against the raw files, then load the result. No: load it raw; transform with SQL; then measure what the raw adds before you build on it. Beneath: PCI DSS, a card's security code must never be stored after the payment is approved, and a stored card number must be unreadable; here, keeping raw cost $0.00218 a month and added +0.014 AUC on one question, a luck-sized +0.008 on another.

SituationOrderWhy
Normal cleaning, joins and sums in a warehouseELTCheap in place, raw history kept, tables can be rebuilt
A field that must never be stored rawETL for that fieldRemove or mask it before it lands
Heavy work that is not SQL, such as images or audioETL or a mixRun it on Spark, then load the result
No warehouse that can run transforms cheaplyETLTransforming in place would cost too much

The sharpest case is a field that must never be stored. The old lesson said GDPR or HIPAA may forbid "ever storing raw personal data, even briefly". Neither law says that. GDPR, the EU's data protection law, says personal data must be "adequate, relevant and limited to what is necessary". It names safeguards such as "pseudonymisation", replacing a name with a code. HIPAA, a US health privacy law, "requires appropriate administrative, physical and technical safeguards".

How Real Teams Describe It

The usual account of why ELT spread is that two costs fell: storing raw data got cheap, and warehouse compute split away from storage. Here are the dated facts behind that account, each from the company's own release, filing, post or code history.

A hand-sketched timeline of seven boxes down a line, headed dates from each company's own filings, releases and posts, titled when keeping raw data got cheap, and SQL got tools. 1979: Teradata is incorporated. 1993: Informatica is incorporated; PowerCenter is its ETL tool. Mar 2006: Amazon S3 launches at $0.15 a GB a month. May 2012: BigQuery opens to everyone, after a 2010 preview. Jun 2015: Snowflake, which separates storage and compute, is generally available. Mar 2016: dbt's first commit. 2026: S3 Standard, $0.023 a GB a month. Beneath: the usual account says storage got cheap, compute split off, and dbt made SQL into code; these dates fit it; nothing here measures it.

Amazon S3 launched on 14 March 2006 at $0.15 per GB a month; its Standard storage lists $0.023 today. BigQuery was shown to a few developers in May 2010 and opened to everyone on 1 May 2012. Snowflake was incorporated in July 2012 and has been "generally available since June 2015". dbt's first commit is dated 10 March 2016. The old version of this lesson put cheap S3 at "~2012" and the warehouse split at "2014-2016". Both were wrong, and the StepDiagram below now has the right dates.

The old lesson also said that Spotify and DoorDash "run warehouse-centric ELT with dbt for the bulk of their analytics and ML feature work". I could not find either company saying that in its own words, so I have cut it. Three companies do describe loading raw first and then transforming with dbt, in their own posts.

A two-column table headed in each company's own words, checked 2026-09-30, titled three teams that load raw, then transform with dbt. Left, loaded raw: GitLab, handbook, the raw database is where data is first loaded into Snowflake; Monzo, 2021, events are streamed to specific append-only projects in BigQuery; Canva, 2024, we ingest first-party data into Snowflake through Amazon S3. Right, then transformed with dbt: all tables and views in prep and prod are controlled, created and updated, via dbt; data developers create data pipelines from these events using dbt; dbt shapes raw data into a format that supports analysis and reporting. Beneath: raw lands first; the models are SQL, in dbt.

GitLab keeps its data team's handbook in public. It says tools such as Stitch and Fivetran move data "into our Snowflake ". It also says "the raw database is where data is first loaded into Snowflake". And it says "all tables and views in prep and prod are controlled (created, updated) via dbt".

Try It Yourself

This script is the lab. It downloads the shop's data and builds the nightly daily table. It computes the 8 daily-table features and the 7 raw features at both cutoffs, and trains the four designs for both questions. It prints each design's AUC and top-10% score, plus the sizes and the cancellations the daily table missed. It needs no GPU. When I ran it on an idle laptop, it finished in about 9 seconds once the data was downloaded. It takes longer when the machine is busy.

A real screenshot of VS Code with etl_elt_demo.py open at the top of the file: the docstring, which says the script needs Python 3 with scikit-learn and pandas and how to run it, and holds the design written on 2026-09-30 before the first run: the data, the ETL job that keeps one row per customer per day, the two questions, buys and cancels, the customers, the two cutoffs, the 8 features from the ETL table and the 7 only the raw lines can give, the four designs, recency, etl, elt and raw90, what is reported, and the bootstrap the report adds; then the first imports. The code that loads the data and builds the daily table is further down. Beneath: copy it from the box on the slide.

Before you run this lab. You need Python 3 and two libraries: pip install scikit-learn pandas. scikit-learn holds the model and the download, and brings NumPy with it; pandas holds the tables. The first run downloads Online Retail II from OpenML (about 15 MB), so it needs an internet connection once. After that, scikit-learn keeps a copy in a folder in your home directory (scikit_learn_data).

I ran it with scikit-learn 1.9.1 on a Mac. scikit-learn runs the same way on Windows and Linux, but I have not checked the numbers there. Another version of scikit-learn may give different decimals, so the first line printed is the version. Give it a file name, python etl_elt_demo.py out.json, and it also saves every number at full precision; that is how results/elt-demo.json was made.

"""ETL or ELT? What a model loses when the raw lines are thrown away.

Lesson 3 of 'Data Engineering for ML', made small. It needs Python 3 with
scikit-learn and pandas (pip install scikit-learn pandas). The first run
downloads UCI Online Retail II from OpenML (about 15 MB) and keeps a copy.
    python etl_elt_demo.py            # print the results
    python etl_elt_demo.py out.json   # and save every number

Design, written 2026-09-30 before the first run:
  Data: every invoice line of a UK online gift shop, 2009-12-01 to
  2011-12-09. An invoice number that starts with C is a cancellation.
  Lines with no customer id are left out of both designs.
  The ETL job turns each day's lines into one row per customer per day,
  net revenue (quantity times price, cancellations count as minus) and
  the number of invoices, and keeps nothing else. ELT keeps the lines.
  Two questions, asked at a cutoff, about the 90 days after it:
    buys     does the customer order anything?
    cancels  does the customer cancel or return anything?
  Customers: everyone with a day of positive revenue in the 270 days
  before the cutoff. Features use only those 270 days.
  Learn at the cutoff 2010-09-10; test at the cutoff 2011-09-10.
  8 features the ETL table can give: recency, days with positive
  revenue, revenue, mean revenue a day, invoices, tenure, days with
  negative revenue, revenue in the last 90 days.
  7 only the raw lines can give: cancelled lines, share of invoices
  that cancel, distinct products, lines per order, median units per
  line, median unit price, share of lines from the UK.
  Four designs, each for both questions:
    recency  one ETL feature: days since the last purchase
    etl      the 8 ETL features
    elt      the 8 and the 7 raw ones
    raw90    the same 15, the 7 from only the last 90 days of lines
  Reported: ROC AUC (the chance a customer who did it is ranked above
  one who did not) and the share who did it among the top 10%. Also
  the size of both tables, the lines the ETL table has no place for,
  and the cost of storing each. HistGradientBoostingClassifier, seed 0,
  one run. The report adds a bootstrap of the test customers (1,000
  draws, seed 0) for the gap between elt and etl, and nothing else.

Author: Roni Das
Created: 2026-09-30
"""
import json
import sys

import numpy as np
import pandas as pd
import sklearn
from sklearn.datasets import fetch_openml
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import roc_auc_score

S3_PRICE = 0.023  # US dollars per GB a month, S3 Standard, first 50 TB
DAY = pd.Timedelta(days=1)

raw = fetch_openml(data_id=43368, as_frame=True, parser="auto").frame
raw_bytes = len(raw.to_csv(index=False).encode())
raw["when"] = pd.to_datetime(raw["InvoiceDate"])
raw["day"] = raw["when"].dt.normalize()
raw["cancel"] = raw["Invoice"].str.startswith("C")
# prices have at most 3 decimals, so money is summed exactly, in 1/1000 GBP
raw["milli"] = (raw["Price"] * 1000).round().astype("int64") * raw["Quantity"]
lines = raw[raw["Customer_ID"].notna()]

# The ETL job: one row per customer per day. Nothing else is kept.
etl = (lines.groupby(["Customer_ID", "day"])
       .agg(milli=("milli", "sum"), invoices=("Invoice", "nunique"))
       .reset_index())
etl_bytes = len(etl.assign(revenue=etl["milli"] / 1000)
                .drop(columns="milli").to_csv(index=False).encode())


def from_etl(T):
    """The 8 features, from the ETL table only, for the 270 days before T."""
    e = etl[(etl["day"] >= T - 270 * DAY) & (etl["day"] < T)]
    g, buy = e.groupby("Customer_ID"), e[e["milli"] > 0].groupby("Customer_ID")
    f = pd.DataFrame({"recency": (T - buy["day"].max()).dt.days})
    f["buy_days"] = buy.size()
    f["revenue"] = g["milli"].sum()
    f["per_day"] = g["milli"].sum() / g.size()
    f["invoices"] = g["invoices"].sum()
    f["tenure"] = (T - g["day"].min()).dt.days
    f["neg_days"] = e[e["milli"] < 0].groupby("Customer_ID").size()
    f["last_90"] = e[e["day"] >= T - 90 * DAY].groupby("Customer_ID")["milli"].sum()
    return f.fillna({"neg_days": 0, "last_90": 0})


def from_raw(T, days, who):
    """The 7 features only the raw lines can give, for `days` before T."""
    r = lines[(lines["day"] >= T - days * DAY) & (lines["day"] < T)]
    sale, gone = r[~r["cancel"]], r[r["cancel"]]
    by, s = r.groupby("Customer_ID"), sale.groupby("Customer_ID")
    f = pd.DataFrame({"cancel_lines": gone.groupby("Customer_ID").size()},
                     index=by.size().index).fillna(0)
    f["cancel_share"] = gone.groupby("Customer_ID")["Invoice"].nunique()
    f["cancel_share"] = f["cancel_share"].fillna(0) / by["Invoice"].nunique()
    f["products"] = s["StockCode"].nunique()
    f["per_order"] = s.size() / s["Invoice"].nunique()
    f["units"] = s["Quantity"].median()
    f["price"] = s["Price"].median()
    f["uk"] = (r["Country"] == "United Kingdom").groupby(r["Customer_ID"]).mean()
    return f.reindex(who)  # a customer with no lines in the window: blank


def cohort(cutoff):
    T = pd.Timestamp(cutoff)
    X = from_etl(T)
    later = lines[(lines["day"] >= T) & (lines["day"] < T + 90 * DAY)]
    y = {"buys": X.index.isin(later.loc[~later["cancel"], "Customer_ID"]),
         "cancels": X.index.isin(later.loc[later["cancel"], "Customer_ID"])}
    tables = {"recency": X[["recency"]], "etl": X,
              "elt": X.join(from_raw(T, 270, X.index)),
              "raw90": X.join(from_raw(T, 90, X.index))}
    return tables, y


def top10(y, p):
    order = np.argsort(-p, kind="stable")[:int(np.ceil(0.1 * len(y)))]
    return float(y[order].mean())


learn, y_learn = cohort("2010-09-10")
test, y_test = cohort("2011-09-10")
n_raw, n_cust = len(raw), len(lines)
out = {"scikit_learn": sklearn.__version__, "raw_lines": n_raw,
       "no_customer_id": n_raw - n_cust, "etl_rows": len(etl),
       "raw_bytes": raw_bytes, "etl_bytes": etl_bytes,
       "learn_customers": len(y_learn["buys"]),
       "test_customers": len(y_test["buys"])}
print(f"scikit-learn {sklearn.__version__}")
print(f"raw lines: {n_raw:,}; no customer id: {n_raw - n_cust:,}")
print(f"ETL table: {len(etl):,} rows, one per customer-day")
for k in ("raw", "etl"):
    mb = out[f"{k}_bytes"] / 1e6
    out[f"{k}_dollars_a_month"] = mb / 1000 * S3_PRICE
    print(f"  {k} as CSV: {mb:5.1f} MB, ${mb / 1000 * S3_PRICE:.5f} a month")
print(f"customers: learn {out['learn_customers']:,}, "
      f"test {out['test_customers']:,}")

# What the netting hid: cancellations that leave no negative day behind.
t = test["elt"]
out["test_cancelled_before"] = int((t["cancel_lines"] > 0).sum())
out["of_them_negative_day"] = int(((t["cancel_lines"] > 0)
                                   & (t["neg_days"] > 0)).sum())
print(f"test customers who cancelled in the 270 days: "
      f"{out['test_cancelled_before']:,}")
print(f"  with a day of negative revenue: {out['of_them_negative_day']:,}")

out["results"] = {}
for q in ("buys", "cancels"):
    out["results"][q] = {"share": float(y_test[q].mean())}
    print(f"{q}: share who did it {y_test[q].mean():.3f}")
    print(f"  {'design':<8} {'AUC':>6} {'top 10%':>8}")
    for d in ("recency", "etl", "elt", "raw90"):
        model = HistGradientBoostingClassifier(random_state=0)
        model.fit(learn[d], y_learn[q])
        p = model.predict_proba(test[d])[:, 1]
        auc, top = float(roc_auc_score(y_test[q], p)), top10(y_test[q], p)
        out["results"][q][d] = {"auc": auc, "top10": top}
        print(f"  {d:<8} {auc:6.3f} {top:8.3f}")

if len(sys.argv) > 1:  # a file name was given: save every number too
    json.dump(out, open(sys.argv[1], "w"), indent=1)

The Lab Report

A real terminal recording headed python elt_report.py, titled every table in this lesson, rebuilt line by line. It opens: Online Retail II, 1,067,371 lines, 2009-12-01 to 2011-12-09; every table rebuilt line by line; 29 checks, all agree. Then six numbered sections: 1, the headline, AUC and top 10 for the four designs on buys and cancels; 2, elt minus etl over 1,000 bootstrap draws, buys +0.014 with a middle 95% of +0.002 to +0.025, cancels +0.008 with -0.008 to +0.025; 3, each raw feature shuffled 30 times, the mean change in AUC with the lowest and highest, cancel_share for cancels -0.013 from -0.022 to -0.004, products for buys -0.014 from -0.020 to -0.008, products for cancels +0.003 from +0.001 to +0.008; 4, the Christmas question, AUC etl 0.658, elt 0.713, elt+ 0.717, elt minus etl +0.055 with a middle 95% of +0.039 to +0.072, elt+ minus etl +0.059; 5, customer 12352's eleven lines on Tuesday 2011-03-01 and the ETL row, revenue 144.35, 4 invoices; 6, the shop as layers, gold 38,502 customer-days of which 5,390 hold only cancellations, and what keeping them costs, raw $0.00218 a month. Beneath: the lab's own report; it rebuilds every table in plain Python, fits every model again, and stops unless every stored number comes back.

The report lives in scripts/labs/dataeng/elt_report.py. It reads the demo's stored files, results/elt-demo.json and the printed run, and the shop's data from scikit-learn's local copy. It does not trust the demo's arithmetic. The demo builds its tables with pandas. The report walks the 824,364 lines one at a time in plain Python, with dictionaries and sets, and builds the daily table and all 15 features again.

Then it fits every model again on its own tables. It also writes its own daily table out as CSV and measures it, so the 1.1 MB is checked too. It stops unless every stored count, size and AUC comes back exactly. All 29 checks agreed. It changes nothing in the demo's files.

Its json mode writes every number to results/elt-report.json, which the figures read. The demo mode checks the demo's printed run line by line. The box mode writes the playground on the next slide and checks it against both files.

Rank the Customers Yourself, No Model

This box has no model in it. It holds all 3,223 test customers, in order. For each one it has the two answers: did they order in the 90 days, and did they cancel? It also has the etl and elt models' scores for both questions, rounded to a thousandth. And it has two counts: cancelled lines, which only the raw lines have, and days of negative revenue, which the daily table shows. Each number is stored as characters in base 64, which just means counting with 64 different digits instead of 10, so that the box stays small. It runs in your browser.

The answers and the two counts are exact. The scores are rounded, so the box's AUC can differ from the stored one in the fourth decimal. The report checked that every box AUC is within 0.002 of the stored one; the largest difference was 0.00005. Because of that rounding, the box prints 0.711 for etl on buys, where the stored AUC rounds to 0.712.

As it is, the box prints each design's AUC and top-10% score for both questions. Then it ranks customers for the cancels question by one count, with no model at all. Ranking by cancelled lines, which only the raw lines have, gives an AUC of 0.709. Ranking by days of negative revenue, the daily table's trace, gives 0.680.

So a single raw count, on its own, ranked cancellers better than the daily table's best clue. Yet the elt model, with all 15 features, beat the etl model by only 0.008. One possible reading, which I did not test: the eight daily features already carry much of what the count knows, such as how often a customer orders.

Then try top10(CANCELLED, DID['cancels']) for the count's top-10% score. Try auc(SCORE[('buys', 'elt')], DID['cancels']), a buys model asked the cancels question. Ask yourself which of these you could have measured without the raw lines.

Working One Customer Out by Hand

Before you trust a transform, I think you should be able to compute its output with a pencil. So here is customer 12352's day, Tuesday 1 March 2011, turned into its row in the daily table by hand.

The first invoice, 545323 at 14:57, has five lines. 3 times 4.95 is 14.85. 6 times 5.95 is 35.70. 4 times 8.50 is 34.00. 12 times 1.65 is 19.80. Postage is 1 times 40.00. Those add up to 144.35.

The next two invoices are cancellations. C545329 has two manual lines, minus 280.05 and minus 183.75. C545330 has one, minus 376.50. The last invoice, 545332, bills the same three amounts again, plus 376.50, plus 280.05 and plus 183.75. The three minuses and the three pluses add up to exactly zero.

So the day's net revenue is 144.35 plus zero, which is 144.35. The day had four invoices: 545323, C545329, C545330 and 545332. That gives the daily table's row: customer 12352, 1 March 2011, revenue 144.35, 4 invoices. The report printed exactly that.

Now the raw features. Over the 270 days, this customer had 8 invoices. 3 of them were cancellations, so the share of invoices that cancel is 3 divided by 8, which is 0.375. They cancelled 10 lines in all. The 8 invoices can be counted from the daily table, by adding up its invoice column. But the share and the cancelled lines cannot: each row holds one sum and one count, and the table does not say which of the invoices cancelled.

Map the pencil work onto the dbt model on the slide "A Feature Table in dbt, Pinned to a Date". sum(revenue) over the days is the addition. count(distinct case when is_cancel then invoice end) / count(distinct invoice) is 3 divided by 8. The warehouse does the same arithmetic for every customer at once.

The Code, Part by Part

Loading. fetch_openml(data_id=43368) downloads Online Retail II once and reads the local copy after that. raw_bytes measures the whole table as CSV, before anything is added to it. milli is quantity times price in thousandths of a pound, as whole numbers. The prices have at most three decimals, so every sum is exact. lines keeps only the lines with a customer id.

The ETL job. One groupby over customer and day, with two numbers: the sum of milli and the count of distinct invoices. That table, etl, is all the nightly job keeps. etl_bytes measures it as CSV.

from_etl(T). The 8 features, from the daily table only, for the 270 days before the cutoff T. Customers with no day of positive revenue in those days are left out, so every customer in a cohort bought something.

from_raw(T, days, who). The 7 features that need the lines, for the days before T. sale and split the lines into orders and cancellations. A customer with no lines in the window gets blanks, and the model handles blanks by itself.

How to Keep Raw Data, Step by Step

A hand-sketched column of seven boxes joined by arrows, headed keeping raw data, step by step, titled load it raw, pin it to a date, measure before you build. 1, load the raw data too, even if today's job needs only a summary. 2, mask or remove card numbers and other such fields before they land. 3, set how long raw personal data is kept, and delete what the law requires. 4, pin every transform to an as-of date, never to today. 5, keep the old summary table as one model over the raw data. 6, before a new model, compare summary features with raw ones. 7, test the keys, and run with dbt build so a failed test stops the rest. Beneath: here, step 6 said +0.014 for buys, a luck-sized +0.008 for cancels, and +0.055 for Christmas items, a question chosen after the results.

Load the raw data too. Even if today's job needs only a summary, store the rows as they arrived. Here that cost $0.00218 a month.

Mask or remove sensitive fields before they land. A card's security code must be dropped, and a card number truncated or tokenized. Masking them later is too late, because the readable values have already been stored.

Set how long raw personal data is kept. GDPR says personal data may be kept "for no longer than is necessary", and people can ask for theirs to be erased. Give the raw layer a retention period, and a way to delete one person's rows.

Pin every transform to an as-of date. A transform that reads today's date cannot rebuild last month's table. Pass the date in.

Keep the old summary table as one model over the raw data. Dashboards that read it keep working, and it can be rebuilt whenever its logic changes.

Before a new model, compare summary features with raw ones. Train once on what the summary can give, and once with raw features added. Then check the gap with a bootstrap. Here that took seconds.

Test the keys, and run with dbt build. A unique test on the invoice line and a not_null test on the customer id stop a duplicated line from inflating someone's revenue.

When to Keep Raw, When to Transform First

A two-column table headed grounded in this lesson's numbers and sources, titled keep it raw, or transform it first? Left, keep it raw when: storage is cheap, 94.8 MB cost $0.00218 a month here; the next question is not known yet, Christmas items, chosen after the results, gained +0.055; you want to test a feature before building a pipeline for it; a transform may have a bug you will need to fix and rerun. Right, transform it first when: a field must not be stored readable, a card's security code or its number; you have no use for a personal field, GDPR says keep only what is necessary; the work is heavy and not SQL, images, audio; there is no warehouse that can run the transform cheaply. Beneath: ELT as a default, not a law; ETL on purpose, field by field.

Keep it raw when storage is cheap next to what a question is worth. Here the raw lines cost $0.00218 a month, and one new question, chosen after the results, gained 0.055 AUC (0.059 with one added feature).

Keep it raw when you do not know the next question. The daily table held up on the two questions near what it recorded. It fell behind on the one about something it never recorded.

Keep it raw when you want to test a feature before building a pipeline. Every gap in this lesson took seconds to measure, because the lines were there.

Keep it raw when a transform may need fixing. A bug in a summary can be fixed and rerun only if the rows under it still exist.

Transform first when a field must not be stored readable. Drop a card's security code, truncate or tokenize its number before load, and load the rest of the row raw.

Transform first when you have no use for a personal field. GDPR says personal data must be "limited to what is necessary". If no question needs it, do not keep it.

Transform first when the work is heavy and not , or when there is no warehouse that can run the transform cheaply.

What These Numbers Can and Cannot Tell You

A two-column page headed read before you trust these numbers, titled what these runs are, and what they are not. They are: one shop, 2009 to 2011; one model type, one run each; two cutoffs, a year apart; a bootstrap, declared first; 30 shuffles, Christmas, one customer, after; sizes as CSV, AWS's list price. They are not: not every business; not every kind of model; not a trend over years; a check on luck, not a proof; chosen knowing the results; not a cloud bill.

One shop, one model type, one run each. Everything here is one UK online gift shop, with many wholesale customers, from 2009 to 2011, and one kind of model. With other data, other questions and other models, the numbers would move.

Two cutoffs, a year apart. The models learned at one September and were tested at the next. That tests one step into the future, not a trend.

The bootstrap checks luck, and nothing else. It asks whether a different set of test customers from the same shop would give the same gap. For buys it held; for cancels it did not.

Some parts came after the results. The four designs, the two questions and the bootstrap were fixed before the first run. The shuffles, the Christmas question and the customer picked by a rule came later. I wrote them down before the report first ran, and the Christmas question in particular was chosen knowing the first gains were small. After a review of this lesson I also raised the shuffles from one to 30, and bootstrapped the Christmas gap for elt, not only for elt+.

Sizes, not bills. I measured the tables as CSV and priced them at AWS's list price. A real warehouse stores data compressed, and adds compute and other charges. I did not measure a cloud bill.

What to Do Next

A hand-drawn list headed before you design the next pipeline, titled five questions for your own data. Kept?: is the raw data kept anywhere, and for how long? As of?: is every transform pinned to a date, not to today? Measured?: has anyone compared summary features with raw ones? Never?: which fields must be masked or removed before they land? Rebuild?: could you rebuild last quarter's training table from raw today? Beneath: here, keeping every line cost $0.00218 a month.

Take one pipeline your team runs and ask the five questions on the card. The first and the last are the quickest. Is the raw data kept anywhere, and could you rebuild last quarter's training table from it today? If the answer to both is no, you have the ETL design from this lesson, whatever the tools are called.

Then do the measurement. Train one model on the features your summary tables give, and one with a few features rebuilt from the raw data. Check the gap with a bootstrap. Here the gap was 0.014 on one question and luck-sized on another. On a question the summary never recorded, which I chose after the results, it was 0.055 (0.059 with one added feature). Your gaps will differ, and now you have a way to find them.

A closing card headed to keep, titled keep the raw; then measure. In large type: $0.00218 a month. Beneath: to keep all 1,067,371 lines in cloud storage. Then: added to the daily table, they moved AUC by +0.014 for buys, and a luck-sized +0.008 for cancels. Then: on a question the table never recorded, chosen after the results, +0.055. Last: one shop, one run each; a way to measure, not a law.

The card keeps the lesson's numbers. Keeping every line cost $0.00218 a month. On two questions near what the daily table held, the lines added 0.014 and a luck-sized 0.008. On a question the table never recorded, chosen after the results, they added 0.055, or 0.059 with one added feature.

The next lesson in this chapter is about data lakes, warehouses and lakehouses. It covers the places the raw data from this lesson can be kept, and how they differ.

Knowledge Check

Knowledge Check

4 questions - Score 80% to pass

Q1

In the lab, what did adding the 7 raw-line features to the 8 daily-table features do for the buys and cancels questions?

Q2

177 test customers cancelled something but never had a day of negative revenue in the daily table. Why?

Q3

The corrected dbt model reads its date from var('as_of') instead of current_date. Why?

Q4

Which field is the lesson's example of one that must be removed before it lands, even when the rest of the row is loaded raw?

Schedule
The copies and transforms run on time, and failures are seen
Airflow
Keep rawThe raw rows are stored, in the warehouse or in cheap filesSnowflake, BigQuery, S3
TransformCleaning, joins and sums, as SQL inside the warehousedbt
Heavy workImages, audio, very large custom jobs in PythonSpark

The important idea is the split in the last two rows. Routine work, such as removing duplicates, fixing types, joining two tables and adding up days, is SQL that runs inside the warehouse with dbt. Work SQL is bad at, such as reading images or running custom Python over billions of rows, goes to Spark. Spark is a system that spreads a job over many machines. Many teams split the work this way, though nothing forces it.

A table of four rows headed the four designs, fixed before the run, titled the same model, given four sets of columns. Recency: one number from the daily table, days since the last purchase. Etl: the 8 features the daily table can give. Elt: the same 8, plus 7 rebuilt from the raw lines. Raw90: the same 15, but the 7 from only the last 90 days of lines. Beneath: every design learns at the 2010 cutoff and is tested at the 2011 cutoff; only the columns change.

The designs differ only in which columns the model gets. Recency is a single number, a baseline to compare with. Etl gets everything the daily table can give. Elt gets that, plus seven features only the raw lines can give. Raw90 gets the same fifteen, but the seven raw ones come from only the last 90 days of lines. It is as if the raw data were deleted after 90 days.

A two-column list headed the 15 features, by where they can come from, titled what the daily table can give, and what it cannot. Left, from the daily table, 8: days since the last purchase; days with positive revenue; revenue; mean revenue a day; invoices; days since the first row; days of negative revenue; revenue in the last 90 days. Right, only from the raw lines, 7: cancelled lines; share of invoices that cancel; distinct products; lines per order; median units a line; median unit price; share of lines from the UK; and whatever the next question needs. Beneath: enough for a finance dashboard; summed away by the nightly job.

I tried to be fair to the daily table. Its eight features include "days of negative revenue", a day when cancellations outweighed orders. It is the one sign in the table that can only mean a cancellation, and a careful team would use it. Cancellation invoices are also counted in the invoices column, but mixed in with orders, so they cannot be told apart there. The seven raw features need the lines: how many were cancelled, how many different products, the typical quantity and price, and the country. The median is the middle value once the values are sorted.

Every model is scikit-learn's HistGradientBoostingClassifier, many small trees of yes-or-no questions built one after another. It uses seed 0: a seed is the fixed starting number for a program's random choices, so a rerun gives the same model. Each design ran once.

At 14:57 the customer ordered four products plus postage. At 15:47 and 15:49 two invoices cancelled three manual charges, which the data codes as M. At 15:52 the same three amounts were billed again. The cancelled and re-billed amounts net to zero. So the daily table shows an ordinary day: revenue 144.35 and 4 invoices.

Over the whole 270 days, this customer cancelled 10 lines and never had a day of negative revenue. To the daily table, they look like a customer who never cancels.

staging
int_
intermediate

The most important line is the date. The old version of this model used current_date, today's date. So the same SQL, run on the same raw rows, gave a different table every day, and a training table from last month could not be rebuilt. This one reads its date from var('as_of'), a value passed in on the command line. dbt's documentation says var() "returns the value defined in your project or passed using --vars". Pin the date, and the same raw rows always give the same table.

The old model also said it ran "inside Snowflake or BigQuery". The SQL was Snowflake's, and BigQuery has no datediff: its function is DATE_DIFF(end_date, start_date, granularity), in a different order. dbt has cross-database functions, such as dbt.datediff, for a model that must run on both.

The counts use plain case sums. Snowflake's count_if would read more easily, but its documentation says it returns "NULL if no records satisfy the condition", not zero. And the old model built its churn label from days_since_last_order in the same table as its features. That is leakage: the answer hidden inside the inputs. The lab avoids it by computing every feature before the cutoff and every answer after it.

incremental

Skipping tests because "it is only SQL". dbt has four ready-made tests: unique, not_null, accepted_values and relationships. Without them, a duplicated line in silver quietly inflates a customer's revenue in gold, and the model trains on it.

The real example of "never store it" comes from the card payment industry's rules, PCI DSS. Its standards council writes that "sensitive authentication data must never be stored after authorization", "even if this data is encrypted". It adds that the card number must be "rendered unreadable anywhere it is stored". A security code is sensitive authentication data, so it is dropped before load.

The card number itself must be made unreadable before it lands. The council lists ways to do that, among them truncation, "removing a data segment, such as showing only the last four digits", and tokens. Those fields go through ETL, while the rest of the row can still be loaded raw. Not every card field is like that. The council's fact sheet lists the card's expiry date as data that may be stored, protected if it is kept with the card number.

For many teams the honest answer is a mix: ELT for most of the work, with a few ETL steps and Spark jobs beside it. Two rules catch most mistakes. Never load a field that must not be stored readable. And before you build a pipeline around a raw feature, measure what it adds, as this lab did.

Monzo, a UK bank, wrote in October 2021 that its events are "streamed via 'the firehose' (using and NSQ) to specific append-only projects in BigQuery". It added that "data developers create data pipelines from these events using dbt".

Canva wrote in November 2024: "We ingest first-party data into Snowflake through Amazon S3". Then dbt makes sure "raw data is shaped into a format that supports analysis and reporting".

None of the three posts says anything about how much the raw data was worth to a model. A team has to measure that part for itself.

This is a real run in VS Code's terminal (python etl_elt_demo.py).

A real screenshot of VS Code's terminal after running python etl_elt_demo.py. It prints scikit-learn 1.9.1; raw lines: 1,067,371; no customer id: 243,007; ETL table: 38,502 rows, one per customer-day; raw as CSV: 94.8 MB, $0.00218 a month; etl as CSV: 1.1 MB, $0.00002 a month; customers: learn 3,238, test 3,223; test customers who cancelled in the 270 days: 1,107, with a day of negative revenue: 930; then buys, share who did it 0.578, with AUC and top 10% for recency 0.648 and 0.709, etl 0.712 and 0.960, elt 0.725 and 0.944, raw90 0.720 and 0.941; and cancels, share 0.177, recency 0.651 and 0.322, etl 0.728 and 0.474, elt 0.736 and 0.495, raw90 0.727 and 0.486.

When I ran it, it printed scikit-learn 1.9.1, the line counts, the two sizes and the two cohorts. For buys it printed AUCs of 0.648, 0.712, 0.725 and 0.720, and for cancels 0.651, 0.728, 0.736 and 0.727. All of it matches the stored elt-demo.json, and the longest printed line was 51 characters. The report's demo mode checks every one of those lines against the file.

To try something I have not run, change the 90 in from_raw(T, 90, X.index) to 30, so the short design keeps only 30 days of raw lines. I cannot tell you what it prints, because I have not run it. The question to ask is how quickly the small buys gain disappears as the raw history gets shorter.

Here is what came before the run, in the demo's docstring: the data, the nightly job, the two questions, the cutoffs, the 15 features and the four designs. The bootstrap was declared there too.

Then there is what came after I saw the results, written into the report's docstring before the report first ran. That covers the shuffles, the Christmas question, the customer picked by a rule, and the layer counts. Every figure that shows one of those says "after the results". Three changes came later still, after a review of this lesson, and the report's docstring says so.

The shuffles went from one to 30. The Christmas bootstrap now covers elt as well as elt+. And the report now counts the lines the nightly job read and the days that hold only cancellations.

Four brand cards headed the tools, with their logos, titled what ran where. scikit-learn: the models, the AUC and the data download. pandas: the demo's daily table and its features. NumPy: the top 10% and the bootstrap. Python: the report's line-by-line rebuild, and the box.

The split between the tools is deliberate. scikit-learn fitted every model and scored every AUC. The demo builds its tables with pandas. The report builds them again in plain Python, one line at a time. So the check that the numbers come back uses a different method from the one that made them. Plain Python also holds the playground, because it has to run in a browser.

gone

cohort(cutoff). Builds the features, then the two answers from the 90 days after the cutoff, and the four tables, one per design. top10 sorts by score and takes the share who did it among the first tenth.

The rest. Learn at 2010-09-10, test at 2011-09-10, and print. The loop at the end fits one HistGradientBoostingClassifier per question and design, with seed 0. With a file name on the command line, json.dump saves every number.