Skip to main content

INSERT and INSERT … SELECT

Level 3: Level 3: Data Modeling & Schema Designmedium25 minINSERT INTO … VALUESINSERT … SELECTmulti-row insertcolumn lists

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 and NULL-keyed junk rows load straight into a clean model. Filter garbage at load time, not later.
  • Column count mismatch. If the SELECT returns 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_id and price_txt to INTEGER,
  • uppercase and trim the SKU (UPPER(TRIM(sku))),
  • trim the product name,
  • and drop rows whose prod_id is NULL with a boundary WHERE.

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_id INTEGER, email TEXT, country_code TEXT)
  • dim_product (product_id INTEGER, sku TEXT, unit_price_cents INTEGER)

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.