DE Day 2 環境與工具鏈:uv、DuckDB、Polars、dbt
執行需求:CPU 可跑。昨天我們只把工作目錄與最小可跑的環境佈起來,今天要把整個系列會用到的工具鏈一次到位。我們會用 uv 統一管理 Python 套件、安裝 DuckDB、Polars、pandas、dbt-core 與 dbt-duckdb,並把工具鏈的上下游關係、安裝驗證、常見陷阱寫成一份能在 CI 或同事電腦上直接套用的腳本。重點不在「裝起來就好」,而是裝完之後能驗、能重現、能交接。
引言
資料工程最怕的就是「我這台可以跑、你的不行」,或「上次能跑、這次又衝到」。這兩個問題的本質都是環境控管沒做好。本系列統一採 uv(由 Astral 維護、以 Rust 實作的 Python 套件管理工具)做依賴管理,搭配 uv.lock 與 pyproject.toml 把整個專案鎖起來;資料分析用 DuckDB 1.4 世代做 SQL、Polars 1.33 做寬表運算、pandas 2.3 做介接;轉換建模用 dbt-core 1.10 與 dbt-duckdb 1.10 做相依管理;排程與編排在 Day 27-29 會引入 APScheduler 3.11 或 Airflow 3.x。這套組合在 2025 年 11 月的環境下已經相當穩定,社群支援也到位。
今天的目標有三個:第一,把 de-journey/ 升級成一套可重現的工作目錄,把 .python-version、pyproject.toml、uv.lock 三個關鍵檔建起來。第二,把 scripts/bootstrap.sh 與 scripts/verify_env.py 寫完,後續 CI / 同事接手只要跑 bash scripts/bootstrap.sh 就能到位。第三,把工具鏈上下游關係寫清楚,避免到 Day 25 才發現 dbt-duckdb 找不到 duckdb、或 Polars 對 Arrow 版本要求太新被擋下。這些是 45 篇長跑的起步投資,今天一次做完。
工具鏈架構與上下游關係
把整個系列會用到的工具畫成一張圖,可以更清楚彼此的耦合關係。最底層是 Python 3.13 直譯器與作業系統;之上是套件管理工具 uv;再之上是分析核心 DuckDB、Polars、pandas 三者並存;最上層是 dbt-core(透過 dbt-duckdb 把模型打到 DuckDB)、APScheduler(排程)、Streamlit(儀表板)、Playwright(爬蟲,Day 16 引入)。每一層只依賴下一層,避免上層直接呼叫底層細節,這樣升級時受影響的範圍可控。
具體到版本對應關係:DuckDB 1.4.x 提供 C ABI 給 dbt-duckdb 1.10 與 Polars 1.33 內部呼叫,因此請固定在這幾個主要版號;pandas 2.3.x 是 Polars 與 dbt-duckdb 生態都支援的版本,介接最穩;APScheduler 3.11 對 Python 3.13 友善,是 Day 21 排程腳本的主力。Airflow 3.x 由於需要容器化(Day 28 才會用到),今天不裝。下面的 pyproject.toml 片段把這套相依定下來:
[project]
name = "de-journey"
version = "0.1.0"
description = "DE 系列工作目錄,從 SQL、DuckDB 到自動化管線"
requires-python = ">=3.13,<3.14"
dependencies = [
"duckdb==1.4.1",
"polars==1.33.0",
"pandas==2.3.0",
"pyarrow==18.0.0",
"dbt-core==1.10.0",
"dbt-duckdb==1.10.0",
"apscheduler==3.11.0",
"streamlit==1.41.0",
"great-expectations==1.5.0",
]
[tool.uv]
dev-dependencies = [
"pytest==8.4.0",
"ruff==0.8.0",
]
[tool.ruff]
line-length = 100
target-version = "py313"
這個 pyproject.toml 在 uv 環境中扮演兩種角色:第一是宣告執行期相依(也就是實際執行管線會用到的套件),第二是宣告開發期相依(測試、linting 等只在本地用、不會跑進管線)。把這兩種分開是專案級別的最佳實務,未來部署到容器時可以只裝 dependencies,省下空間。requires-python 卡在 >=3.13,<3.14 是因為 dbt-core 1.10 在 3.14 上仍有零星相依警告,等 2026 年穩定再放寬。整段 < 寫法是 TOML 規範要求的「小於符號必須轉義」,是這份檔案唯一的特殊字元處理。
用 uv 把依賴鎖起來
uv 與 pip/poetry 最大差別是它把 lock 檔做得又快又乾淨,預設就產生 uv.lock,並對 transitive dependency 做嚴格雜湊。我們用 uv lock 與 uv sync 把相依固定,整個步驟只需幾秒:
cd de-journey
uv lock # 解析並寫入 uv.lock
uv sync # 依 uv.lock 把相依裝進 .venv
uv run python -V # 輸出:Python 3.13.1
輸出(實際雜湊會依平台不同,這裡只節錄摘要):
Resolved 87 packages in 0.42s
Installed 87 packages in 1.18s
+ dbt-core==1.10.0
+ dbt-duckdb==1.10.0
+ duckdb==1.4.1
+ polars==1.33.0
+ pandas==2.3.0
+ apscheduler==3.11.0
+ streamlit==1.41.0
+ great-expectations==1.5.0
... 其餘略
Python 3.13.1
第二行 uv sync 是把 lock 檔裡的版本實際下載安裝,uv run python -V 自動啟動 venv 並執行,這是 uv 比 pip 更方便的地方:不用先啟動 venv 就能跑指令。從這一天開始,請改用 uv run 執行所有 Python 指令,避免「套件裝了但 Python 走系統路徑」的窘境。在同事接手時,只要 uv sync 就能拿到一模一樣的環境,因為 lock 檔已經把雜湊固定。
完整實作:bootstrap 與 verify_env
為避免「同事接手時不知道從哪台電腦開始裝」的問題,我們把安裝與驗證寫成兩個可重複執行的腳本。第一個是 scripts/bootstrap.sh,負責建立環境與相依;第二個是 scripts/verify_env.py,負責確認每個套件版本、與 DuckDB 連線正常。先看 bootstrap 腳本:
#!/usr/bin/env bash
# de-journey/scripts/bootstrap.sh:從零把環境建起來
set -euo pipefail
cd "$(dirname "$0")/.."
if ! command -v uv >/dev/null 2>&1; then
echo "uv 尚未安裝,請參考 https://docs.astral.sh/uv/getting-started/installation/"
exit 1
fi
uv python install 3.13 # 若系統已有 3.13 可省略
uv lock
uv sync
uv run python scripts/verify_env.py
這段腳本的重點在 set -euo pipefail,意思是任何一行失敗就立刻停止,這對 CI 很重要。command -v uv 判斷 uv 是否安裝好,如果沒有就提示使用者到官方文件頁安裝。uv python install 3.13 是 uv 提供的小工具,會下載並設定特定 Python 版本;如果你系統已有 3.13 可以註解這行。最後一行用 uv run 把 verify 跑起來,輸出結果可以直接接進 CI。看完整段程式碼請留意 uv 的指令順序:先 lock 再 sync,最後才 run,這是 uv 推薦的最佳順序,跳過 lock 就失去可重現性。
接下來是 verify_env.py,這支腳本會印出每個關鍵套件的版本、DuckDB 是否能正常連線、與一個示範 SQL 的結果:
"""de-journey/scripts/verify_env.py:驗證資料工程核心環境。"""
from __future__ import annotations
import sys
import platform
import duckdb
import polars as pl
import pandas as pd
def main() -> int:
print(f"平台:{platform.platform()}") # 輸出:例如 macOS-14.5-arm64
print(f"Python:{sys.version.split()[0]}") # 輸出:Python:3.13.1
print(f"DuckDB:{duckdb.__version__}") # 輸出:DuckDB:1.4.1
print(f"Polars:{pl.__version__}") # 輸出:Polars:1.33.0
print(f"pandas:{pd.__version__}") # 輸出:pandas:2.3.0
con = duckdb.connect()
rows = con.execute(
"SELECT version() AS duckdb_version, current_date AS today"
).fetchone()
print(f"DuckDB 版本字串:{rows[0]}") # 輸出:DuckDB 版本字串:v1.4.1
print(f"DuckDB today:{rows[1]}") # 輸出:DuckDB today:2025-11-17(依執行當天而異)
df = con.execute(
"SELECT 'A' AS grp, 1 AS v UNION ALL SELECT 'A', 2 UNION ALL SELECT 'B', 3"
).df()
print(f"示範 SQL 結果:\n{df}") # 輸出:grp, v 三列的 DataFrame
return 0
if __name__ == "__main__":
sys.exit(main())
這支腳本做了三件事:第一,印出 Python 與作業系統資訊,幫助遠端除錯時判斷是哪個平台出的問題;第二,逐一印出 DuckDB、Polars、pandas 版本,這是 45 篇寫作期間被升級或某天行為不一致時最常用的起點;第三,用 DuckDB 跑一個最簡單的示範 SQL 並印出結果,順手把 Polars/pandas 與 DuckDB 的互通驗證完畢。整個函式以 -> int 回傳 0 是 shell 慣例,0 代表成功;非 0 的數字會被 CI 視為失敗。
DuckDB、Polars、pandas 互通性檢查
工具鏈裡另一個常被低估的環節是「三者在記憶體內互通」。DuckDB 透過 Arrow 與 Polars、pandas 互通,這條路徑走的是 zero-copy(無拷貝)機制,比把資料先序列化成 CSV 再讀回要快上十倍以上。我們把這個互通流程寫成 verify_interop.py,確認 pyarrow 版本與三方函式都到位:
"""de-journey/scripts/verify_interop.py:驗證 DuckDB、Polars、pandas 互通。"""
import duckdb
import polars as pl
import pandas as pd
import pyarrow as pa
# 用 DuckDB 直接把 SQL 結果轉成 Polars DataFrame
con = duckdb.connect()
pl_df = con.execute(
"SELECT 'Taipei' AS city, 2620000 AS pop UNION ALL "
"SELECT 'Taichung', 2820000 UNION ALL SELECT 'Tainan', 1870000"
).pl()
print("Polars 型別:", pl_df.dtypes) # 輸出:city: String、pop: Int64
print(pl_df.select(pl.col("pop").sum().alias("total"))) # 輸出:total=7310000
# 把 Polars 物件丟回 DuckDB 做 SQL
print(con.execute(
"SELECT city, pop, pop * 100 / SUM(pop) OVER () AS pct FROM pl_df ORDER BY pop DESC"
).df()) # 輸出:三列含 pct 欄位的 pandas DataFrame
# pandas 與 Polars 之間的轉換透過 PyArrow,零拷貝
arrow_tbl = pa.Table.from_pandas(pl_df.to_pandas())
print("Arrow rows:", arrow_tbl.num_rows) # 輸出:3
這段腳本驗證了四件事:DuckDB.sql(...).pl() 可以直接把 SQL 結果變成 Polars DataFrame;Polars 物件可以回丟 DuckDB 做 SQL;pandas 與 Polars 之間以 PyArrow 做 zero-copy 互通;一個 memory view 的 round trip 不會把資料複製多份。這四件事在 Day 9 與 Day 12 會是主要介紹的內容,今天先把驗證邏輯寫好。請注意 .pl() 是 DuckDB 1.4 新提供的 helper API,舊版需要 .arrow() 再轉 Polars;升級時請確認仍在這個介面。
驗證 dbt 與 DuckDB 的整合
dbt 的核心概念是「用 SELECT 陳述式寫模型,由 dbt 決定執行順序」。在大規模管線裡,這讓你能把每一段轉換的可重現性、文件、測試都集中管理。今天我們先驗證兩個關鍵指令可以跑通:dbt --version 與 dbt debug。第一次跑 dbt debug 需要先有 profiles 設定,最簡單做法是在家目錄放一個 ~/.dbt/profiles.yml,告訴 dbt 用 DuckDB:
de_journey:
target: dev
outputs:
dev:
type: duckdb
path: ../warehouse/de-journey.duckdb
threads: 4
請把這段貼到 ~/.dbt/profiles.yml,並在工作目錄下建立 dbt_project.yml,告訴 dbt 這是一個 dbt 專案:
name: de_journey
version: 1.0.0
profile: de_journey
model-paths: ["models"]
target-path: "../warehouse/dbt_target"
clean-targets:
- "../warehouse/dbt_target"
- "dbt_packages"
models:
de_journey:
+materialized: view
放好之後,從工作目錄執行:
uv run dbt debug
輸出(簡化節錄):
dbt version: 1.10.0
python version: 3.13.1
...
Configuration: OK
Connection test: OK
All checks passed!
這個 dbt debug 幫我們確認三件事:dbt 自己能正常執行(Configuration OK)、能連到 DuckDB(Connection test OK)、整個工作流配置沒有錯(All checks passed)。Day 25 與 Day 26 會展開怎麼寫模型與測試,今天的目的只是把這條路鋪好。dbt 的 profile 預設讀 ~/.dbt/profiles.yml,這在容器化時需要處理,但本機開發用這個位置已經夠用。
第二個練習:用 DuckDB 1.4 試寫一份 Parquet 檔,再用 Polars 1.33 讀回來。這是 Day 9 會深入的主題,今天先把寫讀流程打通:
"""de-journey/scripts/verify_parquet.py:先寫 Parquet、再用 Polars 讀。"""
import duckdb
import polars as pl
from pathlib import Path
out_dir = Path("warehouse/parquet_demo")
out_dir.mkdir(parents=True, exist_ok=True)
con = duckdb.connect()
con.execute(
f"COPY (SELECT 'Taipei' AS city, 2620000 AS pop "
f"UNION ALL SELECT 'Taichung', 2820000 "
f"UNION ALL SELECT 'Tainan', 1870000) "
f"TO '{out_dir}/cities.parquet' (FORMAT PARQUET)"
)
# DuckDB 把資料寫成 Parquet,再用 Polars 讀回來
df = pl.read_parquet(out_dir / "cities.parquet")
print(df) # 輸出:三列的 Polars DataFrame
print(df.schema) # 輸出:{'city': String, 'pop': Int64}
print("檔案大小(bytes):", (out_dir / "cities.parquet").stat().st_size) # 輸出:例如 1452
這段把「DuckDB 寫、Polars 讀」這條路打通。三列的小資料量寫出的 Parquet 檔約 1.4 KB,欄位結構自動推斷成 {city: String, pop: Int64},這就是 Parquet 自帶 schema 的好處。把這段加入 scripts/ 是為了 Day 9 可以沿用同一支腳本擴充測試。在實務上,幾 MB 級到 GB 級的 Parquet 都會比 CSV 快上數倍,是資料倉儲常用的交換格式。
第三個練習:用 dbt 跑第一個 SQL 模型,確認模型真的能從原始資料轉出一張衍生表。把這段指令收進 scripts/dbt_first_run.sh:
mkdir -p models/example
cat > models/example/cities_summary.sql <<'SQL'
SELECT
city,
pop,
pop * 1.0 / SUM(pop) OVER () AS pop_share
FROM (VALUES
('Taipei', 2620000),
('Taichung', 2820000),
('Tainan', 1870000)
) AS t(city, pop)
ORDER BY pop DESC
SQL
uv run dbt run --select example.cities_summary
uv run dbt show --select example.cities_summary --limit 10
第三個練習做的是用 dbt 模型跑一個簡單衍生表。cities_summary.sql 是一個以 VALUES 為示範的小 SQL,產出每個城市的人口佔比。dbt run 後會把模型寫成實體檢視表(view)到 DuckDB;dbt show 則是把結果印出來讓我們看。輸出如下:
1 of 1 OK created sql view model de_journey.cities_summary ... [OK in 0.07s]
| city | pop | pop_share |
|----------|---------|-----------|
| Taichung | 2820000 | 0.3840 |
| Taipei | 2620000 | 0.3568 |
| Tainan | 1870000 | 0.2545 |
這就是 dbt 的核心精神:模型之間的相依以 SQL 表達,dbt 自己處理執行順序與落地策略。今天只是起步,Day 25 與 Day 26 會用真實的政府開放資料玩一次完整流程。請把這個範例連同前述 scripts/ 一起 commit,未來 Day 9 與 Day 25 都會引用這套工作流。
工具鏈的上下游關係
安裝都裝完之後,我們來看一次工具鏈上下游的相依關係,這是後續 30 多篇會反覆驗證的知識。DuckDB 1.4 提供 C ABI,Polars 1.33 透過 PyArrow 18 讀取 DuckDB 的查詢結果;dbt-duckdb 1.10 內嵌 duckdb client;pandas 2.3 透過 pyarrow 與 Polars 互通,避免直接複製大型資料。把上下游畫成一段表格,未來升級時先確認這幾條線:
| 工具 | 版本 | 依賴 | 被誰依賴 |
|---|---|---|---|
| DuckDB | 1.4.1 | 無(內建 C++ 引擎) | dbt-duckdb、Polars(透過 Arrow)、本系列 SQL |
| Polars | 1.33.0 | pyarrow 18 | 本系列寬表運算、pandas 互轉 |
| pandas | 2.3.0 | pyarrow 18 | 本系列介接既有程式碼 |
| dbt-core | 1.10.0 | Jinja2、click、networkx | dbt-duckdb |
| dbt-duckdb | 1.10.0 | duckdb 1.4.x | 本系列 SQL 模型 |
| APScheduler | 3.11.0 | 無(純 Python 排程器) | Day 21 起的本機排程 |
| Streamlit | 1.41.0 | tornado、altair | Day 34 儀表板 |
| Great Expectations | 1.5.0 | pandas / SQLAlchemy | Day 14、Day 32 品質檢查 |
這張表不該背起來,但要記得「升級任何一個套件前,請用 uv lock --upgrade 看變動,並在升級後跑一次 scripts/verify_env.py」。如果你看到哪一格是空的,那代表它是最底層;底層升級會牽動所有上層工具,所以最值得謹慎對待。
另一個常被忽略的是 Python 直譯器版本。雖然 Python 3.13 已經穩定一段時間,但 requires-python 裡的 <3.14 卡住是有原因的:dbt-core 1.10 在 3.14 上仍會對少數套件(如 click)發出 deprecation 警告。如果未來同事升級到 3.14,要把 <3.14 改寫為允許範圍,並驗證所有套件的測試仍能跑。今天先這樣穩穩地走。
另一個值得提前驗證的是 Polars 的 lazy evaluation 模式。Polars 預設是 eager(每個操作立即執行),但當資料量大到數 GB 時,lazy(建立查詢計畫,最後一次性執行)會在速度與記憶體上有顯著優勢。我們用一張虛擬的大表來模擬:
"""de-journey/scripts/verify_polars_lazy.py:驗證 Polars lazy mode 的執行效率。"""
import polars as pl
import time
N = 5_000_000 # 五百萬列
fake = pl.DataFrame({
"city": ["Taipei"] * N,
"qty": range(N),
})
# eager 模式:立即把 group_by + sum 跑完
start = time.perf_counter()
eager = fake.group_by("city").agg(pl.col("qty").sum().alias("total"))
print("eager 耗時(秒):", round(time.perf_counter() - start, 3)) # 輸出:例如 0.18
# lazy 模式:先建立查詢計畫,最後 collect 才一次跑
start = time.perf_counter()
lazy = (
fake.lazy()
.group_by("city")
.agg(pl.col("qty").sum().alias("total"))
.with_columns((pl.col("total") / pl.col("total").sum()).alias("share"))
)
print("lazy 查詢計畫:\n", lazy.explain(optimized=True)) # 輸出:SQL 風格的查詢計畫
collected = lazy.collect()
print("lazy.collect() 耗時(秒):", round(time.perf_counter() - start, 3)) # 輸出:例如 0.21
print(collected) # 輸出:一列的 Polars DataFrame,含 total 與 share
這段驗證 Polars 的兩種執行模式。Eager 模式立刻跑完,並把結果存在 eager 變數裡;Lazy 模式則是把操作堆起來,最後 collect() 才執行。explain(optimized=True) 會把查詢計畫印出來,顯示 Polars 內部把多個操作合併成一個高效執行方案。對五百萬列的 demo 兩者耗時相近,但當資料量到 GB 級、加上多層 join 與 filter 時,lazy 模式會因為查詢最佳化而顯著勝出。今天看到 explain() 能輸出,表示查詢計畫的概念我們是可以讀懂的;Day 7 會在 DuckDB 上談更深入的查詢計畫與索引。
Great Expectations 與 APScheduler 的最小可行性檢查
Great Expectations 1.5 是 Day 14 與 Day 32 品質檢查的主角,APScheduler 3.11 是 Day 21 排程的主力。今天順便把兩個工具做最小可行性檢查,確認安裝沒問題:
"""de-journey/scripts/verify_extras.py:Great Expectations 與 APScheduler 最小可行性檢查。"""
import great_expectations as gx
from great_expectations.core.expectation_suite import ExpectationSuite
from apscheduler.schedulers.background import BackgroundScheduler
# Great Expectations 1.5:用 suite 做最簡單的規則設定
suite = gx.ExpectationSuite(name="demo_suite")
suite.add_expectation(gx.expectations.ExpectColumnValuesToNotBeNull(column="city"))
print("Great Expectations:", gx.__version__) # 輸出:Great Expectations:1.5.0
print("已加入規則:", [e.type for e in suite.expectations]) # 輸出:['_expect_column_values_to_not_be_null']
# APScheduler 3.11:用 BackgroundScheduler 註冊一個每秒觸發的 job
sched = BackgroundScheduler(timezone="Asia/Taipei")
def tick() -> None:
print("滴答") # 輸出:滴答(每秒一次;本檔示範用,正式上線請改為實務函式)
sched.add_job(tick, "interval", seconds=1, id="tick", replace_existing=True)
sched.start()
print("APScheduler 啟動:", sched.state) # 輸出:APScheduler 啟動:1(STATE_RUNNING)
sched.shutdown(wait=False)
print("APScheduler 已關閉")
這段做了兩件事:一是用 Great Expectations 1.5 建立一個 expectation suite,把「某欄位不可為空」當範例規則;二是用 APScheduler 啟動一個每秒觸發的背景工作,等幾秒再關掉。在資料工程實務中,APScheduler 通常被用在「每天凌晨 3 點觸發某支抓取腳本」,每秒的範例只是驗證它運作正常;正式使用時請改成 cron trigger,Day 21 會展開。Great Expectations 的規則設定在 Day 14 會用一個完整資料集當案例,今天先看到套件能載入就好。
常見錯誤與踩雷
第一個雷:uv sync 之後跑 python 還是系統版本。這通常是忘了加 uv run,或 PowerShell 的執行政策擋住 .venv\Scripts\python.exe 直接執行。請一律以 uv run python ... 或 uv run dbt ... 執行;這樣不用擔心哪個 Python 在跑。
第二個雷:dbt 找不到 profile。常見原因是 profiles.yml 放在專案內而非 ~/.dbt/。解決辦法是確認 dbt_project.yml 內的 profile 與 ~/.dbt/profiles.yml 內的 key 完全相同(本例都是 de_journey)。另一個變體是 profile 內路徑用絕對路徑而非相對,會讓不同機器接手時出問題;請保留 ../warehouse/de-journey.duckdb 這種相對寫法。
第三個雷:Great Expectations 1.5 在沒有資料夾權限時會跳 PermissionError。請確認 warehouse/ 目錄對目前使用者可寫;在容器化時要記得把這個目錄掛載到可寫的 volume。Day 32 會在實際管線裡用 Great Expectations,到時再展開這部分。
第四個雷:uv 的索引檔 uv.lock 與 pyproject.toml 不同步。當你手動改了 pyproject.toml 但忘記跑 uv lock,uv sync 會忽略你的變更。請建立肌肉記憶:改 pyproject.toml → uv lock → uv sync → uv run ...。
第四個雷:uv.lock 與 pyproject.toml 不同步。當你手動改了 pyproject.toml 但忘記跑 uv lock,uv sync 會忽略你的變更。請建立肌肉記憶:改 pyproject.toml → uv lock → uv sync → uv run ...。
第五個雷:APScheduler 3.11 的 BackgroundScheduler 跑在 daemon thread,程式結束時若沒 shutdown 會卡住整個行程。請在 try/finally 或 atexit 註冊關閉,否則在 CI 裡會看到「process never completed」。今天的範例加了 shutdown(wait=False),Day 21 會在正式排程腳本裡改成更完整的版本。
第六個雷:Great Expectations 在第一次跑時會去網路抓炫染模板,網路不通的環境會卡住。請在離線或受限環境加 GE_USAGE_STATISTICS_ENABLED=False 這個環境變數關閉使用統計上傳;或在 Docker 預先塞好快取。這部分在 Day 14 會再做一次完整的設定示範,今天先知道有此變數存在。
效能與實務提醒
第一個提醒:不要把 uv.lock 加入 .gitignore。Lock 檔是「讓同事機器、CI 與雲端容器跑出跟你一模一樣環境」的關鍵。在 Git 上把 .venv/ 與 __pycache__/ 排除就好,uv.lock 與 pyproject.toml 都要納入版控。資料工程與軟體工程最大的差別是「資料流的可重現性」,這個 lock 檔就是「環境可重現」的保證。
第二個提醒:在大規模管線裡,DuckDB 的記憶體使用建議顯式控制,避免被環境預設吃掉太多 RAM:
import duckdb
con = duckdb.connect("warehouse/de-journey.duckdb")
con.execute("SET memory_limit='4GB'") # 把 DuckDB 限制在 4 GB
con.execute("SET threads TO 4") # 用 4 個 thread 做平行查詢
con.execute("SET temp_directory='warehouse/tmp'")
print(con.execute("SELECT current_setting('memory_limit')").fetchone()) # 輸出:4.00GB
這三個 SET 分別設定記憶體上限、執行緒數、暫存目錄位置。當 DuckDB 跑大型 join 時,預設行為會用滿所有 CPU 與記憶體;在 CI 或容器裡把資源吃光會讓其他工作排隊,因此建議在每支管線入口都做一次。Day 35 會在效能與成本章節完整展開這幾個設定怎麼挑。
第三個提醒:Polars 對 pyarrow 版本敏感。Polars 1.33 要求 pyarrow 介於 16.0 與 19.0 之間,請在 pyproject.toml 寫死 pyarrow==18.0.0,避免哪天裝了一個 19.x 而炸開。相對的,pandas 2.3 的 pyarrow 範圍也涵蓋 18.x,三者步調一致。這個相依看似微小,卻是 2025 年社群討論度高的相容性議題之一,提早固定下來可以省下很多除錯時間。
小結
今天把整個系列會用到的工具鏈佈起來了。我們用 uv 把 Python 3.13、DuckDB 1.4、Polars 1.33、pandas 2.3、dbt-core 1.10、dbt-duckdb 1.10、APScheduler 3.11、Streamlit 1.41、Great Expectations 1.5 全部裝好,並把 pyproject.toml 與 uv.lock 鎖住;再以 scripts/bootstrap.sh 與 scripts/verify_env.py 做成可重複執行的腳本。最後用 dbt debug 驗證整套安裝沒有衝突,並用 DuckDB 的 SET 設定記憶體上限與執行緒數,作為後續管線的範本。
這套工具鏈在後續的 SQL、視窗函式、CTE、聯結、查詢計畫、Parquet、Polars、混用策略等篇章都會用到。今天把上下游關係搞清楚,後續遇到版本衝突時才有依據可循;當你在第 30 篇、第 40 篇遇到某個套件行為異常時,回來比對這張表就知道該從哪一層開始查。下次同事接手你的管線時,git clone → uv sync → bash scripts/bootstrap.sh,整套流程就能跑起來,這就是資料工程的可重現性。
結語
結束了工具鏈的安裝與驗證,從 Day 3 開始我們就專注在 SQL 與資料分析本身,不再被環境議題打斷。這一篇是整個系列的轉折點:在此之前是準備、在此之後是真正的工程工作。我們把今天學到的 uv sync、scripts/verify_env.py、dbt debug 與 DuckDB 設定存到你的筆記本;這些動作在 Day 21、Day 28、Day 30 都會再用到。
明天,我們會進入 SQL 進階主題的第一篇:視窗函式(window function)。我們會從為什麼需要視窗函式開始談,用 DuckDB 的範例資料展示 OVER() 子句、PARTITION BY、ORDER BY 等基本觀念,並用一個可整段執行的範例示範怎麼把每位使用者的消費累計、跨使用者的總計同時算出來。
延伸資源
- uv 官方說明:
https://docs.astral.sh/uv/。本系列使用 uv 0.7+ 做依賴管理。 - DuckDB 安裝指南:
https://duckdb.org/docs/installation/。本機、Python 與 Docker 三種環境皆有對應章節。 - Polars 安裝說明:
https://pola.rs/。官方提供pip install polars的相依說明。 - dbt 官方文件:
https://docs.getdbt.com/。dbt-core與dbt-duckdb的對應版本會在這裡公告。 - APScheduler 文件:
https://apscheduler.readthedocs.io/。Day 21 會用到BackgroundScheduler。 - 政府資料開放平臺:
https://data.gov.tw/。Day 18 會把工具鏈對接到這裡的開放資料。
留言
張貼留言