Skip to main content

Clean and Analyze a CSV in the MotherDuck UI

Build this recipe with your AI assistant

I want a hands-on, query-by-query walkthrough for loading a CSV, profiling it with SUMMARIZE, cleaning messy columns, and answering an analysis question in the MotherDuck web UI. Help me adapt the "Clean and Analyze a CSV in the MotherDuck UI" recipe to my own data and use case, using it as a guide: https://motherduck.com/docs/cookbook/motherduck-ui

This example is a guided SQL session you run query-by-query in the MotherDuck web UI. It shows the typical interactive flow: load a CSV into a table, profile it with SUMMARIZE, iteratively clean columns (drop bad rows, parse unit sizes into quantity and volume, normalize a dollar-formatted price), then answer an analysis question. It demonstrates the MotherDuck pattern of exploring and reshaping raw data interactively before promoting it to a clean, reusable table.

The dataset (winelist_sample.csv, ~1,500 rows) is a wine merchant offer list: country, region, producer, wine name, vintage, unit size (e.g. 6x75cl), two critic scores, quantity, and a dollar-formatted offer price. It is intentionally messy so the cleaning steps have something to do.

How it works​

The script builds up one cleaning SELECT incrementally. Each step adds one transformation so you can verify it before adding the next.

  1. Load. read_csv_auto infers types and column names from the header.
   CREATE OR REPLACE TABLE winelist AS
SELECT * FROM read_csv_auto(['winelist_sample.csv']);
  1. Profile. SUMMARIZE returns per-column min/max/approx-unique/null-percentage so you can spot the messy columns at a glance.
   SUMMARIZE winelist;
  1. Trim the view, drop bad rows. SELECT * EXCLUDE("Wine Name") hides the long name column so the rest of the table is readable, and WHERE vintage > 1000 removes rows with junk vintages.

  2. Parse the unit size. "Unit size" holds values like 6x75cl. The substr + instr logic splits it on the x delimiter into a bottle count and a volume in centiliters:

   cast(substr("Unit size", 1, instr("Unit size", 'x') - 1) as integer) as qty,
cast(
substr(
"Unit size",
instr("Unit size", 'x') + 1,
length("Unit size") - instr("Unit size", 'x') - 2 -- the trailing "cl" is 2 chars
) as integer
) as volume_cl
  1. Normalize the price and derive metrics. "Offer price" is dollar-formatted ($325.00, $1,290.00). Strip the $ and thousands ,, then cast to a fixed-precision decimal, and derive comparable per-bottle and per-75cl prices:
   cast(replace(replace("Offer price", '$', ''), ',', '') as decimal(10, 2)) as offer_price,
offer_price / (qty * volume_cl) * 75 as price_per_75cl,
offer_price / qty as price_per_bottle

script.sql holds the full sequence with inline comments explaining each step.

The exercise​

The last block answers a concrete question: the price-per-bottle difference between the highest-rated, least-expensive bottle and the lowest-rated, most-expensive bottle, for vintages of 1990 or later.

  • For the rating, it uses coalesce(coalesce("WA score","Vinous score"),-1) so a missing WA score falls back to the Vinous score, and a row missing both sorts last.
  • For price it uses offer_price / qty as price_per_bottle.

The exercise query reads from a table named winelist_clean, which the script never creates. Before running it, persist your cleaned SELECT (the step 5 query) as that table:

CREATE OR REPLACE TABLE winelist_clean AS
SELECT * EXCLUDE("Unit size"),
-- ... the qty / volume_cl / offer_price / price_per_bottle columns from step 5 ...
FROM winelist
WHERE vintage > 1000;

Questions to answer​

  • What CSV (or other source) are you loading, and where does it live (local upload, S3, HTTPS)?
  • Which database and schema should the resulting table live in?
  • Which columns need cleaning, and what are their real formats (delimiters, currency symbols, units)?
  • Do you want to keep the cleaned result as a new table (CREATE TABLE ... AS) or just explore?
  • What analysis question are you trying to answer, and which columns drive the ranking or aggregation?

Caveats​

  • The exercise depends on a table the script does not build. Running the final with cte_cheap_but_good ... block before creating winelist_clean fails with a "table does not exist" (Catalog) error. Persist the cleaned SELECT as winelist_clean first.
  • Column names are case- and whitespace-sensitive. The raw CSV header has Wine name, Quantity, and a price column written as Offer Price (note the surrounding spaces). read_csv_auto normalizes some of this, but the script refers to identifiers like "Wine Name" and "Offer price". If a SELECT errors on an unknown column, run SUMMARIZE winelist (or DESCRIBE winelist) and copy the exact column name, including spaces, into double quotes. Double quotes are for identifiers; single quotes are for string literals like '$'.
  • The unit-size parser assumes a fixed format. substr(..., length(...) - instr(..., 'x') - 2) hard-codes a 2-character trailing unit (cl). A value like 1x150cl parses, but a different unit (ml, L) or a missing x delimiter will silently produce a wrong number or fail the integer cast. Validate qty and volume_cl against the source before trusting the derived prices.
  • price_per_75cl divides by qty * volume_cl. If either parses to 0 or NULL, you get a division-by-zero or NULL. Confirm the parse step is clean before computing the ratio.
  • WHERE vintage > 1000 is a blunt filter, kept only to drop -junk vintages. It is not a real validity check; adjust it for your data rather than assuming it cleans everything.
  • The example is built for the UI's run-one-query-at-a-time flow. Running the whole script.sql in a CLI in one go defeats the inspect-each-step purpose and surfaces the winelist_clean error immediately.

What you'll adjust​

SettingPurposeOptions / example
winelist_sample.csvSource file loaded by read_csv_auto([...])Swap for your own CSV path; the UI also lets you drag-drop a file or read from S3/HTTPS
winelist table nameTarget table created by the CREATE OR REPLACE TABLE stepRename to your dataset, e.g. orders, sales_raw
Database / schemaWhere the table lands; defaults to my_db.main in the UIPick the database with the UI database selector or qualify as db.schema.table
"Unit size" parsingsubstr + instr logic that splits a value like 6x75cl into qty and volume_clAdjust the delimiter (x) and the trailing-unit length (cl, 2 chars) for your format
"Offer price" cleanupreplace(...) strips $ and , before casting to decimal(10,2)Change the symbols stripped, the precision/scale, and the source column name
WHERE vintage > 1000Filter that removes obvious bad rowsReplace with your own validity filter, or drop it
Exercise thresholdsThe analysis query filters Vintage >= 1990 and ranks on coalesce(coalesce("WA score","Vinous score"),-1) and price_per_bottleChange the year cutoff, scoring columns, and ranking metric for your question

Run it​

Prerequisites: a MotherDuck account. Open app.motherduck.com, sign in, and use the SQL editor.

  1. Upload winelist_sample.csv (or your own CSV) using the UI file picker, or reference it from S3/HTTPS.
  2. Open script.sql and run it one statement at a time, top to bottom. Read the output of each query before moving on, that is the point of the walkthrough.
  3. Once the cleaning SELECT looks right, persist it as a clean table so the exercise can read it (see "The exercise" below).

You can also run the script from the DuckDB or MotherDuck CLI, but it is written as a UI walkthrough and is best experienced there: running it all at once skips the inspect-after-every-step loop the example is teaching.

Files​

  • script.sql - the guided SQL walkthrough: loads the CSV, runs SUMMARIZE, builds up the cleaning SELECT step by step (drop bad rows, parse Unit size, normalize the price), and ends with the exercise query.
  • winelist_sample.csv - the intentionally messy source data: ~2,450 rows of wine merchant offers with country, region, producer, wine name, vintage, unit size (e.g. 6x75cl), WA and Vinous scores, quantity, and a dollar-formatted offer price.

Learn more​

  • script.sql is the source of truth, with inline comments on each cleaning step.
  • For deeper MotherDuck or DuckDB SQL questions (CSV reading options, SUMMARIZE, SELECT * EXCLUDE, instr/substr/replace, casting), use the ask_docs_question MCP tool or the MotherDuck docs.