INSERT and INSERT … SELECT
Load rows literally and transform-load from another table.
Why loading is where data quality is won or lost
Every table starts empty. INSERT is how rows get in, and the form you choose decides whether your warehouse stays clean. INSERT ... VALUES writes literal rows you type out, which is how you seed small reference tables (status codes, country lists). INSERT ... SELECT writes rows read from another table and transforms them on the way in. That second form is the entire "T" in ELT: you read raw, cast, trim, and rename inside the SELECT, then land a clean result in a model table. One statement, no application code, no temp files, all inside the database.
The mental model: positional mapping
Both forms fill a named target column list, and neither matches on column names. They map by position: the first output expression fills the first named column, the second fills the second, and so on. The SELECT column names do not have to match the target names, only the order matters. That is why the target column list is not decoration. It is the contract that says which slot each value lands in.
Worked example
raw_product holds dirty text (padded SKUs, prices as strings, some NULL ids). Load a clean dimension straight from it:
-- raw_product:
-- prod_id | sku | prod_name | price_txt
-- '10' | ' a12 ' | ' Widget ' | '1999'
-- NULL | 'x9' | 'junk' | '0'
-- '11' | 'b7' | 'Gadget' | '4950'
INSERT INTO dim_product (product_id, sku, name, unit_price_cents)
SELECT CAST(prod_id AS INTEGER),
UPPER(TRIM(sku)),
TRIM(prod_name),
CAST(price_txt AS INTEGER)
FROM raw_product
WHERE prod_id IS NOT NULL; -- drop junk at the boundary
-- dim_product now holds:
-- product_id | sku | name | unit_price_cents
-- 10 | A12 | Widget | 1999
-- 11 | B7 | Gadget | 4950
Every surviving row is cast, trimmed, and inserted as one set. The NULL-id row never lands.
Seeding with multi-row VALUES
For small static data, one statement carries many rows:
INSERT INTO dim_status (code, label) VALUES
('paid', 'Paid'),
('shipped', 'Shipped'),
('cancelled', 'Cancelled');
Pitfalls
- Skipping the column list.
INSERT INTO t SELECT ...with no list binds to the table's physical column order. The day someone adds or reorders a column in the DDL, values silently slide into the wrong slots. Always name the target columns. - No boundary
WHERE. Forget it andNULL-keyed junk rows load straight into a clean model. Filter garbage at load time, not later. - Column count mismatch. If the
SELECTreturns a different number of expressions than the target list names, the statement errors instead of loading. That is a feature: it catches the mistake at the boundary.
Interview nuance: to load one row per key (the dedup the Practice asks for), SELECT DISTINCT customer_id, email, country_code is the usual first reach, but DISTINCT collapses on the entire projected row, not on customer_id alone. It works here only because you clean the columns first, so a customer's repeated feed lines become byte-identical and fold into one. If a single customer_id carried two different emails, DISTINCT would keep both rows and you would still have duplicate keys. True one-row-per-key needs GROUP BY customer_id with an aggregate, or ROW_NUMBER() OVER (PARTITION BY customer_id ...) filtered to = 1.
Execution mode: you write a multi-statement script against a fresh in-memory SQLite database. Hidden assertions then check row counts, cleaned values, and column types.
Apply
Your turn
The task this lesson builds to.
A raw_product table holds dirty product data (already seeded for you). Create a clean
dim_product and populate it from raw_product with a single INSERT … SELECT:
- cast
prod_idandprice_txttoINTEGER, - uppercase and trim the SKU (
UPPER(TRIM(sku))), - trim the product name,
- and drop rows whose
prod_idis NULL with a boundaryWHERE.
dim_product has columns product_id INTEGER, sku TEXT, name TEXT, unit_price_cents INTEGER.
4 hints and 4 automated checks are waiting in the workspace.
Practice
Make it stick
A second problem on the same idea, so it survives past today.
A single wide raw_feed mixes customer and product columns (already seeded). Split it
into two normalized targets with two INSERT … SELECT statements:
dim_customer(customer_idINTEGER,emailTEXT,country_codeTEXT)dim_product(product_idINTEGER,skuTEXT,unit_price_centsINTEGER)
Clean in flight: lowercase+trim emails, uppercase+trim SKUs, cast ids and price to INTEGER,
uppercase country codes. Deduplicate customers so each customer_id appears once even though the
feed repeats it per product line. Dedup products the same way.
4 hints and 6 automated checks are waiting in the workspace.