跳到主要內容

DE Day 2 環境與工具鏈:uv、DuckDB、Polars、dbt

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 年穩定再放寬。整段 &lt; 寫法是 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 會把工具鏈對接到這裡的開放資料。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...