Data Analysis with Python (pandas, polars, DuckDB) Module 13 – DuckDB: SQL on DataFrames and files
DuckDB pipelines: COPY and partitioned Parquet
Turn raw CSV exports into clean DuckDB tables and partitioned Parquet with COPY, count every stage, and build queries from Python with the relational API.
What you will learn
- Save cleaned data as DuckDB tables in a database file and as Parquet files
- Write partitioned Parquet datasets with COPY … PARTITION_BY and re-run them safely
- Build queries step by step with the relational API
- Inspect query plans with EXPLAIN and EXPLAIN ANALYZE
Before you start
What you’ll learn · Before you start
What you will learn
- Save cleaned data as DuckDB tables in a database file and as Parquet files
- Write partitioned Parquet datasets with COPY … PARTITION_BY and re-run them safely
- Build queries step by step with the relational API
- Inspect query plans with EXPLAIN and EXPLAIN ANALYZE
Before you start
On this page
For three monthly exports, a DuckDB pipeline is a short script that turns raw files into data other people can use. It reads the exports
with stated types, cleans them into tables, keeps those tables in a .duckdb database file, and publishes the result
with COPY … TO as Parquet, often split into one folder per month or per region so that readers open only the part
they need. Every step is one SQL statement, so the whole pipeline is easy to read and to re-run. What makes it
trustworthy is not the statements but the checks between them: the row counts of every stage must add up, and a
second run must not change the answer. Use a pipeline like this for analyst-scale data, millions of rows on one
machine, where a warehouse would be more system than the job needs. This lesson builds one from three monthly CSV
exports, counts every stage, and names the statements and options a pipeline uses.
Stage counts explain where all sixteen lines went
Three monthly exports of invoice lines arrive in raw/. July’s has one line exported twice, August’s has a date
typed as 2026-08-3O with a letter O, and September’s includes a refund:
pipeline/raw/sales_2026-07.csv
invoice,line,sold_on,city,sku,qty,amount
INV-701,1,2026-07-03,Pune,BK-101,2,900.00
INV-701,2,2026-07-03,Pune,BK-102,1,299.00
INV-702,1,2026-07-11,Delhi,BK-103,3,1950.00
INV-702,1,2026-07-11,Delhi,BK-103,3,1950.00
INV-703,1,2026-07-19,Chennai,BK-101,10,4500.00
INV-704,1,2026-07-28,Pune,BK-104,1,350.00 pipeline/raw/sales_2026-08.csv
invoice,line,sold_on,city,sku,qty,amount
INV-801,1,2026-08-02,Delhi,BK-102,5,1495.00
INV-802,1,2026-08-09,Chennai,BK-103,2,1300.00
INV-803,1,2026-08-3O,Pune,BK-101,1,450.00
INV-804,1,2026-08-17,Pune,BK-104,4,1400.00
INV-805,1,2026-08-25,Delhi,BK-101,2,900.00 # Raw CSV exports -> a staging table -> clean tables in a database file -> Parquet partitioned by month,
# with the row counts of every stage written down and reconciled.
import os
import duckdb
TYPES = {"invoice": "VARCHAR", "line": "INTEGER", "sold_on": "DATE", "city": "VARCHAR", "sku": "VARCHAR", "qty": "INTEGER", "amount": "DECIMAL(10,2)"}
with duckdb.connect("analytics.duckdb") as db:
# 1. Stage: every file, with its name, and every line that fails kept aside. Stating the date format makes the
# reader strict: without it, the typo 2026-08-3O would load as 2026-08-03.
db.execute(f"""
CREATE OR REPLACE TABLE staged AS
SELECT *, filename AS source
FROM read_csv('raw/*.csv', header = true, types = {TYPES}, dateformat = '%Y-%m-%d', store_rejects = true)
""")
rejected = db.sql("SELECT count(*) FROM reject_errors").fetchone()[0]
print("set aside:", db.sql("SELECT line, column_name, csv_line FROM reject_errors").fetchall())
# 2. Clean: one row per invoice line, refunds in a table of their own.
db.sql("""
CREATE OR REPLACE TABLE sales AS
SELECT DISTINCT ON (invoice, line) * EXCLUDE (source) FROM staged WHERE amount > 0 ORDER BY invoice, line
""")
db.sql("CREATE OR REPLACE TABLE refunds AS SELECT * EXCLUDE (source) FROM staged WHERE amount < 0")
# 3. Publish: one folder per month, readable by any tool that reads Parquet.
db.sql("""
COPY (SELECT *, date_trunc('month', sold_on)::DATE AS month FROM sales ORDER BY sold_on, invoice, line)
TO 'sales_by_month' (FORMAT parquet, PARTITION_BY (month), OVERWRITE)
""")
lines_in_files = sum(sum(1 for _ in open(os.path.join("raw", f))) - 1 for f in os.listdir("raw"))
staged, sales, refunds = (db.sql(f"SELECT count(*) FROM {t}").fetchone()[0] for t in ("staged", "sales", "refunds"))
duplicates = staged - sales - refunds
print(f"lines in the files {lines_in_files} = staged {staged} + rejected {rejected}")
print(f"staged {staged} = clean sales {sales} + refunds {refunds} + duplicate lines {duplicates}")
assert lines_in_files == staged + rejected
published = duckdb.sql("SELECT month, count(*) AS lines, sum(amount) AS amount FROM read_parquet('sales_by_month/*/*.parquet') GROUP BY month ORDER BY month")
print(published)
print("files:", sorted(os.path.join(os.path.basename(d), f) for d, _, fs in os.walk("sales_by_month") for f in fs)) Output
set aside: [(4, 'sold_on', 'INV-803,1,2026-08-3O,Pune,BK-101,1,450.00')] lines in the files 16 = staged 15 + rejected 1 staged 15 = clean sales 13 + refunds 1 + duplicate lines 1 ┌────────────┬───────┬───────────────┐ │ month │ lines │ amount │ │ date │ int64 │ decimal(38,2) │ ├────────────┼───────┼───────────────┤ │ 2026-07-01 │ 5 │ 7999.00 │ │ 2026-08-01 │ 4 │ 5095.00 │ │ 2026-09-01 │ 4 │ 4947.00 │ └────────────┴───────┴───────────────┘ files: ['month=2026-07-01/data_0.parquet', 'month=2026-08-01/data_0.parquet', 'month=2026-09-01/data_0.parquet']
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pipeline.py
The staging table: A working table that retains input rows for typed reading, auditing and subsequent transformation before publication. reads every file with stated types and a stated date format, keeps each row’s file name, and sets
aside the line it cannot read. The date format matters more than it looks: without it, DuckDB’s cast reads
2026-08-3O as 2026-08-03 and the typo becomes a plausible wrong date, while dateformat = '%Y-%m-%d' makes the
reader strict, so the line goes to reject_errors with its line number. The cleaning step keeps one row per invoice
line and moves the refund to a table of its own. The publishing step writes one Parquet folder per month.
The two printed equations are the pipeline’s real output. Sixteen lines in the files are 15 staged plus 1 rejected, and 15 staged are 13 clean sales, 1 refund and 1 duplicate. An idempotent output: Output whose declared observable result remains the same when the same operation is repeated with the same input. keeps its stated result when the same input is processed again. A pipeline that cannot write these lines down cannot say whether it lost or invented data.
Use the 16 = 15 + 1 and 15 = 13 + 1 + 1 equations to account for each original export line.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- The three monthly files contain sixteen data lines. One has an invalid date under the declared format.
- Strict reading stages fifteen lines and records the invalid line as a reject. The first accounting equation is therefore 16 = 15 + 1.
- Cleaning retains thirteen sales, sets aside one refund and removes one duplicate. The second equation is 15 = 13 + 1 + 1.
Checkpoint
Check: Stage counts explain where all sixteen lines went
Keep working tables separate from published files
A reader must support both the Parquet data types and the dataset’s partition conventions.
The stages of the lesson's pipeline, and the counts that tie them together
Text description of the diagram
The diagram is a flow read from top to bottom, with one side note.
- It starts with the raw CSV exports in a folder, as they arrive.
- A staged table reads them with read_csv, with the types and the date format stated, keeps the file name of each row, and leaves the lines that fail in the reject_errors table.
- The clean tables live in the database file analytics.duckdb: sales holds one row per invoice line, and refunds are kept in a table of their own.
- COPY with PARTITION_BY (month) publishes the sales as Parquet files in one folder per month, named month= followed by the month's first day, each holding a file called data_0.parquet.
- Dashboards, pandas, polars and other DuckDB jobs read the published files.
A dashed side note says that the counts reconcile at every stage: the lines in the files equal the staged rows plus the rejected lines, and the staged rows equal the clean sales plus the refunds plus the duplicate lines removed.
The .duckdb file is the pipeline’s workspace: tables keep their types, queries on them are fast, and the next run
can compare against them. The Parquet folders are its output: compatible Parquet dataset readers can read them, and their folder names are the
partition values, month=2026-07-01. Only one process may write to a DuckDB file at a time, while several may read
it with read_only=True (concurrency), which is a reason to
publish Parquet for readers rather than share the working database.
Follow the writable analytics.duckdb tables to three published month folders and their reader boundary.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- The pipeline's database holds typed staging, sales and refunds tables. It is the writable workspace of the process that owns the build.
- Parquet output separates reader files by partition value. Compatible readers reconstruct the month column from the folder name.
- Readers query published files rather than opening the working database for concurrent writing. Publication also needs an explicit hand-off and recovery protocol.
Checkpoint
Check: Keep working tables separate from published files
Choose storage by update and read requirements
Start with what changes and what a reader needs. The database table supports updates to working rows, while a Parquet hand-off is usually replaced as a file or dataset version. Partitioning helps queries that select its keys; it does not automatically reduce every scan. CSV can be appropriate for a spreadsheet export even though it requires explicit parsing and type choices. Check reader support for the stored types and folder conventions before choosing a hand-off format. A count of columns projected by a query describes requested output, not a universal prediction of total bytes or time.
| Criterion | Table in a .duckdb file | One Parquet file | Partitioned Parquet | CSV file |
|---|---|---|---|---|
| Readable by | DuckDB clients | Compatible Parquet readers | Readers supporting the partition layout | Text parsers with agreed types and encoding |
| Types kept | Yes | Yes | Yes, partition values as folder names | No, only text |
| Changing part of it | UPDATE, DELETE, INSERT | Rewrite the file | Rewrite one folder | Rewrite the file |
| Reading part of it | Fast, with indexes and statistics | Columns and row groups | Folders a filter selects, then columns | The whole file |
| When to choose | The pipeline's own working data | A hand-off of one table | Large data read by month or region | People who open it in a spreadsheet |
Compare mutable working tables, selected Parquet columns and a CSV reader’s text interpretation.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- A DuckDB table supports row changes inside the working database. Sharing that file also brings its connection and concurrency requirements.
- Parquet supports typed column chunks and row groups for compatible readers. Partitioned output adds folder pruning when filters use the partition keys.
- CSV is widely readable but supplies text that readers must parse and type. Its field layout does not provide Parquet's column chunks.
Checkpoint
Check: Choose storage by update and read requirements
Each statement names one pipeline operation
CREATE OR REPLACE TABLE … AS SELECT …builds a table from a query and replaces the previous version.INSERT INTO t BY NAME SELECT …appends rows, matching columns by name rather than position.COPY (query) TO 'file.parquet' (FORMAT parquet)writes a query’s result to a file.PARTITION_BY (month)writes one folder per value, such asmonth=2026-07-01/data_0.parquet.OVERWRITE_OR_IGNORE,OVERWRITEandAPPENDdecide what a second run does with files already there.FILENAME_PATTERNnames the files inside each folder, with an index{i}or a random{uuid}.EXPORT DATABASEwrites a whole database as files plus scripts, andIMPORT DATABASEreads it back.- The relational API builds the same queries from Python method calls such as
.filter()and.aggregate(). EXPLAINshows the plan a query will run, andEXPLAIN ANALYZEruns it and reports what each step did.
Default partitioned COPY refuses a nonempty target. APPEND repeats four sales as eight rows. OVERWRITE removes existing output; OVERWRITE_OR_IGNORE replaces matching filenames and may leave others. The twelve-file March fixture reads one file with the month-folder filter and all twelve with the sale-date filter inside the files.
- The Excel extension reads
.xlsxfiles withread_xlsxand writes them withCOPY … (FORMAT xlsx).
Identify which operation changes a working table, writes output, describes a plan or exports a database.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- CREATE OR REPLACE defines a table from a query. INSERT BY NAME appends values by column names rather than source position.
- COPY writes query results. PARTITION_BY maps distinct month values to folder names; rerun options state how existing output is treated.
- EXPLAIN describes the plan, EXPLAIN ANALYZE executes it, and EXPORT DATABASE writes schema and load scripts with table files.
Checkpoint
Check: Each statement names one pipeline operation
Partitioned writes that can be re-run: overwrite modes and reproducible output Pro: opens with a Pro, Premium or Ultimate pass
The first run of a pipeline is easy; the second run is where data gets doubled or lost. This part reproduces what each overwrite option does on a second run, measures what “the same output” can mean, and ends with an exercise that builds a dataset safe to repeat.
In this part:
- Rerun options can double rows or remove untouched months
- Content checks and byte checks make different promises
- A checked staging build needs a defined publication boundary
3 diagrams · 3 runnable examples (Python) · about 8 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
The relational API: queries built from Python, and the SQL behind them Pro: opens with a Pro, Premium or Ultimate pass
The relational API builds a query from method calls on Python objects, which suits pipelines whose filters or columns come from configuration. This part composes a report that way, proves it equal to the SQL, and weighs when each form reads better.
In this part:
- A relation chain keeps filters visible before fetching rows
- Use a relation chain when report structure is composed
2 diagrams · 1 runnable example (Python) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Reading plans with EXPLAIN and EXPLAIN ANALYZE, and moving a database Pro: opens with a Pro, Premium or Ultimate pass
A plan tells you what a query will read before you wait for it. This part compares two filters that return the same
answer while one opens twelve times as many files as the other, then moves a whole database with EXPORT DATABASE.
In this part:
- A folder filter can prune eleven of twelve files
- Export writes table files and the scripts that restore them
2 diagrams · 2 runnable examples (Python) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise the declared data rules
Practice that adapts to you
Practise this lesson
9 questions about 11 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
-
Fresher to mid · Warm-up
What does COPY … TO … (FORMAT parquet, PARTITION_BY (month)) write, and how do readers use the result?
It writes one folder per distinct value of the partition column, named in the Hive style, column=value, each holding the Parquet files of that value (data_0.parquet by default). The partition value lives in the folder name rather than inside the files. A reader such as DuckDB with hive_partitioning, or pandas and polars through PyArrow, turns the folder names back into a column, and a filter on that column, such as one month, opens only the matching folders. Choose a partition column that queries filter on and that has few values, such as a month or a region, never an id. A second run into the same folder fails unless an overwrite option says what to do.
-
Fresher to mid
A nightly job writes each day's sales with COPY … PARTITION_BY (sale_date) and the APPEND option. After an operator re-ran Tuesday's job, Tuesday's revenue doubled on the dashboard. Explain what happened, and design a write that can be re-run safely.
-
Mid to senior
A dashboard asks for one month of a dataset partitioned by month, yet every query takes as long as a full scan. How do you find out why, and what do you change?
-
Mid to senior
Five analysts share one 20 GB analytics.duckdb file on a network drive. Their notebooks and two scheduled jobs keep failing with lock errors, and once a file was damaged. What is going wrong, and how would you organise the data instead?
Key takeaways
- A pipeline stages raw files with stated types, cleans them into tables in a
.duckdbfile, and publishes Parquet withCOPY … TO, partitioned into folders such asmonth=2026-07-01. - Stating the date format makes the CSV reader strict; DuckDB’s plain cast read
2026-08-3Oas2026-08-03. - Write down the row counts of every stage, and make them reconcile: lines in the files, staged, rejected, clean, refunds and duplicates.
- Publish Parquet for readers and keep the database file as the pipeline’s own workspace, because only one process may write to it at a time.
Terms in this lesson
- content fingerprint
- A compact summary used to compare data under a chosen method; matching finite hash summaries can still hide collisions.
- idempotent output
- Output whose declared observable result remains the same when the same operation is repeated with the same input.
- partition pruning
- Excluding dataset partitions from a scan using predicates that can be evaluated from their partition values.
- staging table
- A working table that retains input rows for typed reading, auditing and subsequent transformation before publication.
References
- COPY statement (DuckDB) (DuckDB Foundation)
- Partitioned writes (DuckDB) (DuckDB Foundation)
- EXPORT and IMPORT DATABASE (DuckDB) (DuckDB Foundation)
- Relational API (DuckDB Python client) (DuckDB Foundation)
- EXPLAIN (DuckDB guides) (DuckDB Foundation)
- EXPLAIN ANALYZE (DuckDB guides) (DuckDB Foundation)
- Excel extension (DuckDB) (DuckDB Foundation)
- Concurrency (DuckDB) (DuckDB Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress