DuckDB 保姆级教程:从第一条 SQL 到千万行电商经营分析,一篇讲透
很多人第一次觉得 Pandas “不太够用了”,并不是因为它突然变慢了。
更常见的过程是这样的:
几百 MB 的 CSV,read_csv() 一把读进去,没什么感觉;到了 2、3 GB,开始等得有点久;再往上,merge() 跑一次内存就往上蹿,于是你开始删中间变量、改 dtype、到处 del df_tmp,甚至一边跑代码一边盯着活动监视器。
这些办法当然都有用。
但数据继续变大以后,会慢慢冒出一个更根本的问题:
为什么这份几十 GB 的原始数据,一定要先完整塞进 Pandas,才能开始分析?
这也是我写这篇 DuckDB 教程真正想解决的问题。
DuckDB 最有意思的地方,不只是 SQL 跑得快,而是它可以直接对 CSV、Parquet、JSON 这些文件做扫描、过滤、Join 和聚合。很多时候,几千万行原始数据根本不需要全部进入 Python 内存——先让 DuckDB 把数据压到真正需要的几千、几万行,再交给 Pandas 做画图、机器学习或者报表,整个处理思路都会轻很多。
所以这篇文章不会只列一遍 DuckDB 的 API。
我会从安装开始,一直做到千万行级数据实战:怎么直接查 CSV / Parquet,怎么过滤、聚合、Join,怎么和 Pandas 配合,以及什么时候该继续用 Pandas,什么时候把前面的重活交给 DuckDB。
关键示例都附了本机实际运行记录;其余语法按 DuckDB 官方 current 文档核对。
你可以直接跟着代码一路敲下来。
但比记住这些语法更重要的是,读到最后,希望你会多养成一个习惯:
拿到一个大文件,先别急着 pd.read_csv()。
运行环境:macOS,Python 3.11,duckdb 1.5.5,pandas 3.0.3。
01为什么越来越多的 Python 用户开始用 DuckDB
很多人的数据分析经历都差不多。CSV 只有几十 MB 的时候,Pandas 一切都很自然:pd.read_csv()、df[df["amount"] > 1000]、df.groupby("city")["amount"].sum()。
然后数据开始长大:
300 MB:没问题
1 GB:read_csv() 开始变慢
3 GB:开始写 del df_tmp、gc.collect()
5 GB:merge / groupby 可能爆内存
10 GB:已经不是优化一两行 Pandas 的问题
这些优化——只读必要列、改 dtype、分块读取、删中间 DataFrame——都没有错。但再往前一步,会撞到一个更根本的问题:
为什么为了得到一个可能只有 30 行的统计结果,我要先把整个 20 GB 文件完整变成一个 Python DataFrame?
这不是Pandas 不行。很多任务本来就不应该把全部原始数据读进 Python 内存。DuckDB 提供了另一种思路:
20 GB 文件
↓
DuckDB 直接扫描
↓
尽早筛选、只处理需要的列
↓
聚合
↓
30 行结果
↓
再交给 Pandas / Matplotlib
DuckDB 到底是什么
定义:嵌入式分析型数据库。
嵌入式:不需要像传统数据库那样安装服务 → 启动 → 配置端口 → 创建账号 → 设置密码 → Python 建连接。多数时候只要 pip install duckdb然后import duckdb就完了。分析型:主要面向 OLAP——扫描大量数据、筛选、聚合、排序、Join、窗口分析、批量处理。它不是围绕每秒大量用户同时修改一行订单这种在线事务场景设计的。
和 Pandas、SQLite、PostgreSQL 放在一起看:
df[...] | ||
pl.scan_parquet() | ||
OLTP 和 OLAP 不用背定义,用两种提问方式理解就够:OLTP 更像这笔订单现在是什么状态?——每次改动很小、频率很高;OLAP 更像过去半年哪个城市贡献最高?——一次扫描几百万行,最后只输出几十行。
所以理解 DuckDB 最好的方式不是它是一个会 SQL 的 Python 包,而是:它把一个分析型数据库执行引擎直接放进了你的本地进程。这句话能解释后面所有现象——为什么它喜欢 Parquet、为什么擅长 GROUP BY 和 Join、为什么不需要服务器也有用。
025 分钟上手:第一条 DuckDB 查询
安装:
pip install duckdb
验证并跑第一条 SQL:
import duckdb
duckdb.sql("SELECT 42 AS answer").show()
输出:
┌────────┐
│ answer │
│ int32 │
├────────┤
│ 42 │
└────────┘
没有服务器、没有账号、没有端口——一个 SQL 分析引擎已经在你的 Python 进程里工作了。
DuckDB 有两种使用形态:
一次性查询引擎: duckdb.sql(...)用默认的内存连接,Python 退出后数据不保存,适合临时分析。持久化本地数据库: duckdb.connect("analytics.duckdb"),目录里会出现一个.duckdb文件,建的表下次打开还在。
con = duckdb.connect("analytics.duckdb")
con.execute("CREATE TABLE demo AS SELECT 1 AS id, 'DuckDB' AS name")
con.close()
con = duckdb.connect("analytics.duckdb")
con.sql("SELECT * FROM demo").show()
临时分析和持久数据库,用途不同,别混在一起用。
03用 Pandas 思维补齐数据分析 SQL
会一点 Pandas、但 SQL 不熟?不用怕,两者的心智模型能对上。本文的示例数据是贯穿全文的三张电商表:orders.csv、users.csv、products.csv。
最惊喜的第一步:CSV 不需要先导入数据库。
import duckdb
duckdb.sql("""
SELECT *
FROM read_csv('orders.csv')
""").show()
不需要 CREATE TABLE,不需要 IMPORT。文件本身就可以是查询对象。
第一步先别分析,先看类型。
duckdb.sql("""
DESCRIBE
SELECT *
FROM read_csv('orders.csv')
""").show()
你以为 order_id 是 INTEGER、amount 是 DECIMAL、order_date 是 DATE——实际自动推断可能和你想的不一样。数据分析真正的第一步往往是确认数据到底被读成了什么,而不是直接 GROUP BY。
SELECT 与 WHERE
选择列:
SELECT order_id, city, amount
FROM read_csv('orders.csv');
对应 Pandas:df[["order_id", "city", "amount"]]。
筛选金额大于 1000 的订单:
SELECT *
FROM read_csv('orders.csv')
WHERE amount > 1000;
对应 Pandas:df[df["amount"] > 1000]。
多条件用 AND / OR;候选值用 IN ('北京', '上海', '深圳');区间用 BETWEEN 500 AND 5000;文本模糊匹配用 LIKE '小%';去重用 DISTINCT city;排序用 ORDER BY amount DESC;只想看几行用 LIMIT 5。
一个非常实用的习惯:分析大文件时先 LIMIT 看几行,别一上来 SELECT * 让终端吐几十万行。
SQL 的逻辑执行顺序
SQL 是声明式语言——你告诉数据库要什么结果,而不是像 Python 循环那样逐步说怎么做。记住一个简化心智模型:
FROM
↓
WHERE
↓
GROUP BY
↓
HAVING
↓
SELECT
↓
ORDER BY
↓
LIMIT
以后进入复杂 SQL,别从第一行读到最后一行的字面顺序,要不断问:当前处理的是原始行?还是已经分组后的结果?还是窗口函数产生的分析列? 理解这一点,WHERE、HAVING、QUALIFY 三者就不容易混了。
另一个工程习惯:先写能跑的 SQL,再逐步加复杂度。先 SELECT * FROM ... LIMIT 10 确认数据源,再加 WHERE,加 GROUP BY,最后加 Join 和窗口。这和写 Python 函数时先跑通最小版本是同一个习惯。
04真正的数据分析 SQL:GROUP BY、HAVING、CASE WHEN、日期
前面的 SELECT / WHERE / ORDER BY 主要是在拿数据。从 GROUP BY 开始,才真正进入分析。
哪个城市卖得最多:
SELECT
city,
SUM(amount) AS revenue
FROM read_csv('orders.csv')
GROUP BY city
ORDER BY revenue DESC;
一次算出一组核心指标:
SELECT
city,
COUNT(*) AS orders,
SUM(amount) AS revenue,
ROUND(AVG(amount), 2) AS avg_order_value,
MAX(amount) AS max_order
FROM read_csv('orders.csv')
GROUP BY city
ORDER BY revenue DESC;
关键不在于语法输赢,而在于:当数据还留在 CSV / Parquet 文件里时,DuckDB 可以直接做这一步。
一个很 DuckDB 的写法:GROUP BY ALL
传统 SQL 要手写所有分组字段:
GROUP BY city, user_id
DuckDB 支持 GROUP BY ALL:SELECT 里所有没有被聚合的列,都作为分组字段。字段一多,省很多重复代码。
WHERE 和 HAVING 的区别
一句话:WHERE 过滤原始行,HAVING 过滤聚合结果。
-- WHERE:先去掉 < 500 的订单,再聚合
WHERE amount >= 500
GROUP BY city
-- HAVING:先聚合,只保留销售额 >= 3000 的城市
GROUP BY city
HAVING SUM(amount) >= 3000
CASE WHEN:数据分析里真正的高频工具
订单价值分层:
SELECT
order_id,
amount,
CASE
WHEN amount >= 5000 THEN '高价值'
WHEN amount >= 1000 THEN '中价值'
ELSE '普通'
END AS order_level
FROM read_csv('orders.csv');
以后很多业务逻辑都能写成 CASE ... WHEN ... THEN ... ELSE ... END——比如 VIP 销售额、用户分群、条件聚合。
日期分析
真正分析时通常不关心某一天,更常见的是每月 GMV、每周订单量、最近 30 天。按月聚合:
SELECT
DATE_TRUNC('month', order_date) AS month,
COUNT(*) AS orders,
SUM(amount) AS revenue
FROM read_csv('orders.csv')
GROUP BY ALL
ORDER BY month;
NULL 是最值得提前讲清的坑
SQL 里 NULL 不是 0,也不是空字符串——它表示缺失 / 未知。错误写法:WHERE value = NULL;正确写法:WHERE value IS NULL。 补默认值可以用 COALESCE(value, 0)。
05多表分析:JOIN 与 CTE
真实业务里一张表通常不够。订单表里有 user_id 和 product_id,但用户等级在 users.csv,商品品类在 products.csv。于是需要 JOIN。
SELECT
o.order_id,
o.user_id,
u.user_name,
u.level,
o.amount
FROM read_csv('orders.csv') AS o
LEFT JOIN read_csv('users.csv') AS u
ON o.user_id = u.user_id;
LEFT JOIN 可以理解成:左边订单表里的行尽量全部保留,然后去右边找匹配信息。找不到就返回 NULL。再 Join 商品表就能算毛利,SQL 开始进入真实业务——收入最高的品类,不一定毛利最高。
CTE 是把复杂 SQL 拆成可读步骤的工具:
WITH enriched_orders AS (
SELECT o.*, u.level, p.category, p.cost
FROM read_csv('orders.csv') AS o
LEFT JOIN read_csv('users.csv') AS u ON o.user_id = u.user_id
LEFT JOIN read_csv('products.csv') AS p ON o.product_id = p.product_id
),
city_stats AS (
SELECT city, COUNT(*) AS orders, SUM(amount) AS revenue
FROM enriched_orders
GROUP BY city
)
SELECT *
FROM city_stats
ORDER BY revenue DESC;
可以看成三个步骤:先拼出 enriched_orders,再算 city_stats,最后排序输出。核心不是语法,而是把一条复杂 SQL 拆成多个可读步骤——SQL 也需要工程化。
06窗口函数:从会统计到会分析
如果只讲到 GROUP BY,其实只完成了一半。窗口函数解决另一类问题:既想做组内计算,又不想把每一行压缩掉。
GROUP BY city 之后,北京变成一行;但每个城市金额最高的 3 笔订单需要保留订单明细。于是用 ROW_NUMBER():
SELECT
city,
order_id,
amount,
ROW_NUMBER() OVER (
PARTITION BY city
ORDER BY amount DESC
) AS ranking
FROM read_csv('orders.csv');
PARTITION BY city 表示每个城市单独算,ORDER BY amount DESC 表示城市内部按金额从高到低。
DuckDB 的 QUALIFY 很舒服
如果只要每个城市前两笔:
SELECT
city,
order_id,
amount,
ROW_NUMBER() OVER (
PARTITION BY city
ORDER BY amount DESC
) AS ranking
FROM read_csv('orders.csv')
QUALIFY ranking <= 2
ORDER BY city, ranking;
在一些 SQL 里你需要再套一层 SELECT * FROM (...) WHERE ranking <= 2,而 QUALIFY 专门过滤窗口函数结果。可以和 HAVING 类比:
WHERE 过滤普通行
HAVING 过滤 GROUP BY 聚合结果
QUALIFY 过滤窗口函数结果
窗口函数一旦掌握,排名、环比、同比、累计、移动平均、组内 Top N 都会顺起来。LAG 可以算本月和上月差多少,SUM(revenue) OVER (ORDER BY month) 可以算累计 GMV。
07CSV、Parquet、JSON:DuckDB 最舒服的数据入口
DuckDB 一个非常重要的特征:文件本身就可以成为查询对象。
CSV 最常见,但也最容易脏。真实 CSV 会遇到:分隔符不是逗号、没有表头、编码异常、NULL 写成 NA、日期格式奇怪、某列前 1 万行是数字后面突然出现字符串。所以最重要的建议是:自动推断很好用,但不要把自动推断理解成永远正确。 面对陌生 CSV 先 DESCRIBE。
字段类型不对可以显式传类型:
SELECT *
FROM read_csv(
'orders.csv',
types = {
'order_id': 'VARCHAR',
'amount': 'DECIMAL(18,2)',
'order_date': 'DATE'
}
);
特别是 ID(001234 如果真是商品编码,应该是字符串而不是整数 1234)、金额、日期——不要完全依赖自动猜。
金额为什么最好理解 DECIMAL:教学或一般统计用 DOUBLE 很方便,但涉及精确货币计算时,DECIMAL(18,2) 表达总精度 18 位、小数 2 位的定点数。不要把数据库能算小数理解成所有金额都无脑用浮点数。
CSV 转 Parquet:我很推荐的动作
COPY (
SELECT *
FROM read_csv('orders.csv')
)
TO 'orders.parquet'
(FORMAT PARQUET);
然后 SELECT * FROM read_parquet('orders.parquet') 直接查。
为什么 Parquet 特别适合分析
CSV 是一行一行文本;Parquet 是列式格式,带 schema 和统计元数据。假设一张表有 50 列,但查询只需要 city 和 amount——对 Parquet 这样的列式数据,DuckDB 可以把需要的列尽量下推到扫描阶段,只读必要的列参与计算。这就是 Projection Pushdown。过滤条件也可以下推到扫描阶段,少读无关数据——这就是 Filter Pushdown。
一个很实用的工作流:外部导出 CSV → DuckDB 清洗校验 → 统一 schema → 转 Parquet → 后续反复分析 Parquet。清洗时甚至可以用 TRY_CAST:转换失败得到 NULL,而不是让整条查询失败,之后再专门检查 WHERE amount IS NULL。
JSON 支持 read_json('events.json'),适合 API 导出、日志、事件流这些半结构化数据;Excel 能用但别抢主线——办公数据能接进来就够了。
08一个目录几百个文件怎么分析
现实数据经常不是一个 orders.parquet,而是:
data/
├── 2026-01-01.parquet
├── 2026-01-02.parquet
└── ...
DuckDB 可以直接用 glob:
SELECT *
FROM read_parquet('data/*.parquet');
多个文件直接作为一张逻辑表查询。还能拿到文件名排查这条异常数据到底来自哪个文件:
SELECT filename, COUNT(*) AS rows
FROM read_parquet('data/*.parquet')
GROUP BY filename;
大数据目录常见的 Hive 风格分区(year=2026/month=01/)DuckDB 也能识别,分区字段可以直接当普通列查。如果每次都写很长的一串 glob 很烦,可以建 View:
CREATE OR REPLACE VIEW orders AS
SELECT *
FROM read_parquet('data/orders/**/*.parquet');
物理上很多 Parquet,逻辑上就是一张 orders。一张值得收藏的决策表:
read_csv() | |
.duckdb | |
.df() / Arrow / Polars |
09DuckDB 为什么快:不是因为SQL 比 Python 高级
DuckDB 快,不是因为 SQL 语法天生比 Python 快,而是因为背后是一个专门为分析型任务设计的执行引擎。这里不用深挖数据库论文,但至少理解四个概念:
列式分析:分析查询往往只关心少数列。即使表有 100 列,真正参与计算的可能只有 city和amount两列。列式数据 + 列式扫描减少无意义的数据搬运。向量化执行:逐行==第 1 行算、第 2 行算、第 3 行算……==vs 一批数据批量处理。DuckDB 的执行引擎围绕分析型批处理专门设计,对聚合、过滤、Join 尤其重要。 查询优化与下推:你写的是 SELECT ... FROM ... WHERE ... GROUP BY,数据库不会机械地按文字从上到下执行,而是构建执行计划做优化——Filter Pushdown、Projection Pushdown,更进一步还有 Join 顺序、表达式优化。Out-of-Core 超内存计算:分析型系统必须面对数据可能比内存大。DuckDB 的一些操作在内存压力下能用磁盘临时空间 spill 处理超内存工作负载。但必须马上强调:支持超内存计算 ≠ 永远不会 OOM。某些结构、索引、并发线程、中间结果和缓冲区仍然会消耗大量内存。
所以后面我们会专门讲 SET memory_limit 和 SET threads。
101000 万行实测:一场公平的 DuckDB vs Pandas 实验
到这里必须离开 15 行小数据。最好的方法不是引用一张别人机器上的跑分,而是自己生成 1000 万行。
用 DuckDB 直接造数据:
import duckdb
con = duckdb.connect("benchmark.duckdb")
con.execute("""
CREATE OR REPLACE TABLE orders_big AS
SELECT
i AS order_id,
'U' || CAST(i % 100000 AS VARCHAR) AS user_id,
CASE i % 6 WHEN 0 THEN '北京' WHEN 1 THEN '上海'
WHEN 2 THEN '深圳' WHEN 3 THEN '杭州'
WHEN 4 THEN '成都' ELSE '广州' END AS city,
CASE i % 4 WHEN 0 THEN '数码' WHEN 1 THEN '家居'
WHEN 2 THEN '服饰' ELSE '配件' END AS category,
ROUND(random() * 10000, 2) AS amount,
DATE '2024-01-01' + CAST(i % 730 AS INTEGER) AS order_date
FROM range(10000000) AS t(i);
""")
con.execute("""
COPY orders_big
TO 'orders_10m.parquet'
(FORMAT PARQUET, COMPRESSION ZSTD)
""")
导出成 Parquet,我们就有一份真正能做压力测试的文件。
测试任务要尽量一样
从 1000 万行订单中筛选金额大于 5000 的记录,按城市聚合订单数、销售额和客单价,按销售额降序。
DuckDB:
import time
import duckdb
start = time.perf_counter()
duck_result = duckdb.sql("""
SELECT
city,
COUNT(*) AS orders,
SUM(amount) AS revenue,
AVG(amount) AS avg_order_value
FROM read_parquet('orders_10m.parquet')
WHERE amount > 5000
GROUP BY city
ORDER BY revenue DESC
""").df()
print(f"DuckDB: {time.perf_counter() - start:.3f}s")
Pandas:
import time
import pandas as pd
start = time.perf_counter()
df = pd.read_parquet("orders_10m.parquet")
pandas_result = (
df[df["amount"] > 5000]
.groupby("city", as_index=False)
.agg(
orders=("order_id", "count"),
revenue=("amount", "sum"),
avg_order_value=("amount", "mean"),
)
.sort_values("revenue", ascending=False)
)
print(f"Pandas: {time.perf_counter() - start:.3f}s")
本机运行记录
同一份 orders_10m.parquet、同一个任务、各自先 warm-up 一次再跑 3 次取中位数:
(配图:DuckDB vs Pandas 1000 万行时间 + 峰值内存对比柱状图)
不要急着宣布谁吊打谁
一个严谨的测试至少要注意:文件缓存(第一次读取和第二次读取可能不同)、环境(CPU/内存/SSD/系统)、版本组合、是否包含文件读取时间、是否真的需要把完整结果 materialize。如果 DuckDB 从 Parquet 读、而 Pandas 用的是已经在内存里的 DataFrame,这不是同一个任务。
所以更合理的表述不是DuckDB 永远比 Pandas 快 X 倍,而是:在从列式文件扫描 → 筛选 → 聚合 → 返回小结果的任务里,DuckDB 的执行模型非常有优势;具体速度必须以你的数据、硬件和查询为准。
还应该测峰值内存。对本地分析而言,4 秒和 6 秒的差异,未必比8 GB 内存和 28 GB 内存的差异重要。 一场可信 benchmark 至少记录:机器(CPU/内存/磁盘/系统)、软件版本(Python/DuckDB/Pandas/PyArrow)、数据(行数/列数/文件大小/压缩)、任务(读哪些列/过滤什么/聚合什么/返回几行)、方法(跑 5 次、第一次 warm-up、取后 4 次中位数)。
11EXPLAIN ANALYZE:第一次看懂数据库执行计划
SQL 写出来以后,我们经常只关心结果对不对。性能问题真正出现时,还要问它到底怎么执行的。
EXPLAIN
SELECT
city,
SUM(amount) AS revenue
FROM read_parquet('orders_10m.parquet')
WHERE amount > 5000
GROUP BY city;
EXPLAIN 给出查询计划,你可能看到类似概念:
PARQUET_SCAN
↓
FILTER / scan filter
↓
PROJECTION
↓
HASH_GROUP_BY
↓
RESULT
重要的不是背算子名字,而是开始学会问:读了什么?过滤在哪里发生?处理了多少行?什么时候聚合?有没有不必要的大中间结果?
EXPLAIN ANALYZE 会真正执行并给出运行时统计——各个算子耗时、处理行数、实际运行路径。调慢 SQL 时非常重要。较新的 DuckDB 还能把计划输出成 text / json / html / graphviz / mermaid 等格式,教程作者甚至可以直接转成可视化素材。
12内存、线程与性能调优
数据库性能调优最危险的一句话是线程拉满就行。不一定。
SET threads = 4;
SET memory_limit = '4GB';
更多线程意味着更高的潜在并行度,但也意味着更多并行任务、更多每线程状态、更多内存压力——操作系统开始 swap,反而更慢。所以:CPU 多,不代表线程永远应该开满。
memory_limit 也不是DuckDB 进程保证永远不超过 4 GB。官方 OOM 文档明确提醒,一些内存使用并不完全经过 buffer manager,所以它是重要控制项,但不是进程总 RSS 的硬保险丝。
听起来很反直觉:内存不够时,官方甚至建议把 memory_limit 调低。因为给 buffer manager 留得太满,可能让其他不受该限制完全控制的内存没有余量。OOM 时可以:降低线程数、把 memory_limit 设到低于默认比例、某些大文件读写关闭 preserve_insertion_order。
如果工作负载会 spill 到磁盘,机械硬盘、网络盘、NVMe SSD 的体验完全不同。数据库性能从来不只是 CPU 跑分,还包括 RAM、磁盘、文件格式、数据布局、查询结构、线程、是否命中缓存。
13完整实战:用 DuckDB 做一次电商经营分析
前面所有知识,最后在一个项目里闭环。三张表 orders / users / products 统一 Join 成一个分析 View:
CREATE OR REPLACE VIEW enriched_orders AS
SELECT
o.order_id, o.user_id, o.product_id, o.city, o.amount, o.quantity, o.order_date,
u.user_name, u.level, u.register_date,
p.product_name, p.category, p.cost,
o.amount - p.cost * o.quantity AS gross_profit
FROM read_csv('orders.csv') AS o
LEFT JOIN read_csv('users.csv') AS u ON o.user_id = u.user_id
LEFT JOIN read_csv('products.csv') AS p ON o.product_id = p.product_id;
以后所有分析都 FROM enriched_orders。回答 8 个经营问题:
问题 1:总盘子多大。 COUNT(*) AS orders、COUNT(DISTINCT user_id) AS users、SUM(amount) AS revenue、SUM(gross_profit)、AVG(amount)。问题 2:哪个城市贡献最高。 GROUP BY city ORDER BY revenue DESC。问题 3:哪个品类贡献最高。 加 gross_margin_pct把销售额和毛利区分开——这一步开始见真章,收入最高的品类不一定毛利最高。问题 4:VIP 贡献了多少。 GROUP BY level,或直接用SUM(CASE WHEN level = 'VIP' THEN amount ELSE 0 END) / SUM(amount)算 VIP 收入占比。问题 5:月度趋势。 DATE_TRUNC('month', order_date)按月聚合,再用LAG(revenue) OVER (ORDER BY month)加环比:
WITH monthly AS (
SELECT DATE_TRUNC('month', order_date) AS month, SUM(amount) AS revenue
FROM enriched_orders
GROUP BY ALL
),
with_lag AS (
SELECT month, revenue,
LAG(revenue) OVER (ORDER BY month) AS previous_revenue
FROM monthly
)
SELECT month, revenue, previous_revenue,
ROUND((revenue - previous_revenue) / NULLIF(previous_revenue, 0) * 100, 2) AS mom_growth_pct
FROM with_lag
ORDER BY month;
问题 6:每个城市 Top 3 商品。 把 CTE + GROUP BY + ROW_NUMBER + QUALIFY 一次串起来:
WITH product_city AS (
SELECT city, product_name, SUM(amount) AS revenue
FROM enriched_orders
GROUP BY city, product_name
)
SELECT city, product_name, revenue,
ROW_NUMBER() OVER (PARTITION BY city ORDER BY revenue DESC) AS ranking
FROM product_city
QUALIFY ranking <= 3
ORDER BY city, ranking;
问题 7:新老用户差异。 先定义口径:注册后 30 天内的订单视为新用户期订单。
SELECT
CASE
WHEN order_date <= register_date + INTERVAL 30 DAY THEN '新用户期'
ELSE '成熟用户期'
END AS user_stage,
COUNT(*) AS orders,
COUNT(DISTINCT user_id) AS users,
SUM(amount) AS revenue,
ROUND(AVG(amount), 2) AS avg_order_value
FROM enriched_orders
GROUP BY ALL;
注意:新用户怎么定义,本质上是业务口径,不是 SQL 自己决定的。
问题 8:输出经营分析宽表。 城市级宽表包含订单数、用户数、销售额、毛利、毛利率、VIP 收入、VIP 占比,最后 .df()交给 Pandas:
city_report = duckdb.sql("""
-- 上面的 SQL
""").df()
city_report.to_excel("city_business_report.xlsx", index=False)
到这一步,整条链路闭环:CSV/Parquet → DuckDB → JOIN → CTE → GROUP BY → WINDOW → 业务指标 → Pandas → Excel / 图表。
真正做经营分析时,SQL 之前还有三件事
第一,定义口径。GMV 到底是什么——创建订单金额还是支付金额?是否含退款?含不含运费?两个人 SQL 都写对了,但口径不同,结果可以完全不同。最好在注释里写清。
第二,检查数据覆盖范围。做月度趋势前先看 MIN(order_date)、MAX(order_date)、COUNT(*)。否则数据只更新到 8 月 3 日,你却拿 8 月和完整 7 月比较,很容易得到一个业务暴跌的假结论。
第三,做 sanity check。销售额突然翻 10 倍,先别急着写结论——可能是 Join 产生重复行、金额单位从元变分、某批数据重复导入。一个简单检查:COUNT(*) 是否明显大于 COUNT(DISTINCT order_id)。
真正可靠的分析能力是:语法 + 数据理解 + 口径 + 验证,缺一不可。
14最容易踩的坑
看到 DuckDB 就想把所有 Pandas 重写掉。 没必要。3 万行 20 列,Pandas 秒出结果,就继续用。 所有 CSV 都无脑相信自动类型推断。 生产数据一定要先 DESCRIBE。ID 被当成整数。 001234变成1234,业务含义已经变了。ID 通常更接近字符串。金额全部用 DOUBLE。 浮点数和十进制定点数不是一回事,财务口径要谨慎。 写 value = NULL。 应该value IS NULL。Parquet 明明能直接查,却先完整读 Pandas。 如果目的只是 SQL 聚合, SELECT ... FROM read_parquet('50GB.parquet')更自然。DuckDB 查完大表后立刻 .df()拉回几千万行。 前面省下的内存,最后一步又全部花回去。正确姿势是 DuckDB 聚合出小结果再.df()。线程数越多越快。 不一定,更多线程也意味着更多内存压力和竞争。 DuckDB 能 spill,所以永远不会 OOM。 支持超内存工作负载 ≠ 绝对不会内存不足。 一次性分析和持久化数据库混在一起。 临时查询用 duckdb.sql(...),持久项目用duckdb.connect(...),先想清楚用途。只比较运行时间,不比较整个工作流。 一个含文件读取、一个用内存里的数据,比较没有意义。 为了显得高级,把所有 SQL 写成一条 200 行巨型查询。 SQL 也需要工程化——用 CTE、VIEW、清晰命名、统一口径、分层。可维护性比炫技重要。
15什么时候该用 DuckDB?
很适合: 大 CSV / Parquet(几百 MB 到几十 GB)、大量 GROUP BY / JOIN、多文件数据集、Jupyter 临时分析、本地 ETL、给 Pandas 做前置减负——这是最推荐的组合之一。
不一定适合: 很小的数据(2 万行 Pandas 已经足够)、高并发在线业务数据库、主要逻辑是 Python 函数(复杂 NLP、模型推理)、团队已有成熟数仓(BigQuery/Snowflake/ClickHouse/Trino)时不需要为用 DuckDB 把数据搬出来重算。
最终决策思想:不要为了 DuckDB 而 DuckDB,而是根据数据规模、任务形态和现有技术栈分工。
16一页速查表
安装 pip install duckdb
导入 import duckdb
第一条查询 duckdb.sql("SELECT 42").show()
查 CSV SELECT * FROM read_csv('data.csv');
查 Parquet SELECT * FROM read_parquet('data.parquet');
多 Parquet SELECT * FROM read_parquet('data/*.parquet');
过滤 WHERE amount > 1000
排序 ORDER BY amount DESC
分组 GROUP BY city
Friendly SQL GROUP BY ALL
JOIN FROM orders o LEFT JOIN users u ON o.user_id = u.user_id
CTE WITH base AS (...) SELECT * FROM base;
窗口函数 ROW_NUMBER() OVER (PARTITION BY city ORDER BY amount DESC)
过滤窗口结果 QUALIFY ranking <= 3
CSV 转 Parquet COPY (SELECT * FROM read_csv('data.csv')) TO 'data.parquet' (FORMAT PARQUET);
结果转 Pandas duckdb.sql("...").df()
查 Pandas duckdb.sql("SELECT * FROM df")
持久数据库 con = duckdb.connect("analytics.duckdb")
View CREATE VIEW orders AS SELECT * FROM read_parquet('orders/*.parquet');
查询计划 EXPLAIN SELECT ...;
实际性能分析 EXPLAIN ANALYZE SELECT ...;
线程 SET threads = 4;
内存限制 SET memory_limit = '4GB';
17写在最后
DuckDB 真正改变的,是一种数据处理习惯
学到这里,如果最后只记住一句 import duckdb,其实还不够。
我更希望你顺手改掉一个用了很多年的默认习惯:
拿到数据以后,不要第一反应就是先把它完整塞进 Python 内存。
以前我们做数据分析,路径通常很自然:
大文件 → 全部读成 DataFrame → 再开始过滤、Join、聚合、分析。
数据小时,这么做没有任何问题。但数据一旦变大,真正值得先问的其实是另一件事:
我最后真正需要的数据,到底有多少?
假设原始数据有 3000 万行,但你最终只是做几次过滤、Join 和聚合,最后得到 3000 行结果。
那真正应该进入 Pandas 的,从来就不一定是前面的 3000 万行。
完全可以先让 DuckDB 把重活干掉:
CSV / Parquet / JSON
↓
DuckDB
↓
扫描 / 过滤 / Join / 聚合
↓
小规模分析结果
↓
Pandas / Polars / Arrow
↓
可视化 / ML / Excel / 报表
到了这一步,DuckDB 的位置其实就很清楚了。
它不是来替代 Pandas 的,也不是让你为了处理几个文件,重新部署一整套数据平台。
它更像是在文件和 Python 之间,补上了一层非常趁手的查询与计算引擎。
数据只有几十 MB、几百 MB 时,这层分工可能没什么存在感,直接 read_csv() 也完全够用。
但当文件一路从 100 MB 长到 5 GB、20 GB,甚至 50 GB,问题就开始变了。
这时候真正需要优化的,往往已经不是:
“这一行 Pandas 怎么才能再快一点?”
而是更前面那个问题:
这一整批数据,真的有必要先全部进 Pandas 吗?
如果你开始习惯先问这句话,那么这篇 DuckDB 教程真正想讲的东西,基本就已经学会了。