快轉到主要內容
  1. 教學文章/

Python marimo 實戰:可重現的 Reactive Notebook 與資料小工具

·7 分鐘· loading · loading · ·
Python Marimo Notebook Data-App Developer-Tools Reactive
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 58: 本文

featured

一. 前言:Notebook 很方便,但也很容易變成考古現場
#

Notebook 是資料分析和原型開發的好朋友。 你可以一格一格跑、邊看結果邊改、把圖表和解釋放在同一個地方。 但拍拍君也要老實說:Notebook 很容易變成一種微妙的黑魔法。 常見狀況大概長這樣:

  • 第 3 格其實依賴第 17 格先跑過。
  • 變數在記憶體裡,但檔案裡看不到它從哪裡來。
  • Git diff 看到一大坨 JSON,review 起來眼神死。
  • 想把 notebook 變成小工具,最後又重寫成 Streamlit。 今天要介紹的 marimo,想解決的就是這些問題。 它的核心概念很直接:
  • Cell 之間有明確依賴關係。
  • 變數改變後,相關 cell 自動更新。
  • 檔案本質上是乾淨的 Python script。
  • 可以同時當 notebook、互動式 app、命令列腳本使用。 如果你之前看過拍拍君寫的 StreamlitDuckDB,可以這樣分工:
  • Streamlit:適合做給別人用的資料 Web App。
  • DuckDB:適合用 SQL 快速分析檔案資料。
  • marimo:適合把探索過程做成可重現、可互動、好 review 的 notebook。 今天我們會做一個小型銷售資料探索工具,從資料產生、互動篩選、圖表到匯出一次走完。

二. 安裝:先建立一個乾淨專案
#

先建立專案:

mkdir marimo-demo
cd marimo-demo
uv init
uv add marimo pandas altair duckdb

如果你不用 uv,也可以用一般虛擬環境:

python -m venv .venv
source .venv/bin/activate
pip install marimo pandas altair duckdb

確認 marimo 可以啟動:

marimo --version

建立第一個 notebook:

marimo edit sales_report.py

注意檔名是 sales_report.py,不是 .ipynb。 這是 marimo 很重要的設計:notebook 直接存成 Python 檔。 對 Git、code review、formatter、搜尋工具都比較友善。

三. 第一個 marimo Notebook:每個 cell 都是 Python
#

開啟編輯器後,你可以建立第一個 cell:

import marimo as mo
import pandas as pd

再建立第二個 cell:

mo.md(
    """
    # Sales Report
    這是一份用 marimo 做的小型互動式資料報告。
    """
)

mo.md() 會把 Markdown 內容渲染到 notebook 裡。 這點跟 Jupyter 類似,但 marimo 的 cell 仍然是普通 Python。 你可以把 Markdown、UI 元件、資料處理都放在 Python script 裡。 如果你打開 sales_report.py,會看到它不是一坨難懂 JSON,而是類似這樣的程式:

import marimo
__generated_with = "0.12.0"
app = marimo.App()
@app.cell
def _():
    import marimo as mo
    import pandas as pd
    return mo, pd
@app.cell
def _(mo):
    mo.md("# Sales Report")
    return
if __name__ == "__main__":
    app.run()

這就是它適合放進專案的原因。 你可以 git diff,可以 review,也可以搜尋某個函式或變數。

四. Reactive Cell:變數不是隨便亂飄的
#

marimo 跟傳統 notebook 最大的差異,是它會追蹤 cell 之間的依賴關係。 假設你有一個 cell:

tax_rate = 0.05

另一個 cell:

subtotal = 1200
total = subtotal * (1 + tax_rate)
total

當你改 tax_rate,使用到 tax_rate 的 cell 會自動重新執行。 這聽起來很小,但它解決了 notebook 最煩的問題:執行順序不可信。 在傳統 notebook 裡,你可能先跑下面的格子,再回去改上面的格子,最後畫面看起來對,實際狀態卻不一定對。 marimo 比較像 spreadsheet:

  • 上游變數改了,下游結果就更新。
  • 沒有被依賴的 cell 不會亂跑。
  • 同一個變數不能在多個 cell 重複定義。 這個限制一開始會讓人覺得「怎麼這麼嚴格」。 但寫久會發現,這正是它讓 notebook 可重現的關鍵。

五. 準備範例資料:做一份小型訂單表
#

我們先建立一個 cell 產生資料。

from random import Random
rng = Random(42)
products = [
    ("keyboard", "hardware", 1200),
    ("mouse", "hardware", 600),
    ("monitor", "hardware", 4200),
    ("notebook", "stationery", 80),
    ("sticker", "stationery", 30),
    ("coffee", "food", 120),
]
cities = ["Taipei", "Tainan", "Taichung", "Kaohsiung"]
orders = []
for order_id in range(1, 401):
    product, category, base_price = rng.choice(products)
    month = rng.randint(1, 4)
    day = rng.randint(1, 28)
    quantity = rng.randint(1, 5)
    discount = rng.choice([0, 0, 0, 0.05, 0.1])
    orders.append(
        {
            "order_id": order_id,
            "date": f"2026-{month:02d}-{day:02d}",
            "city": rng.choice(cities),
            "product": product,
            "category": category,
            "price": base_price,
            "quantity": quantity,
            "discount": discount,
        }
    )
df = pd.DataFrame(orders)
df["date"] = pd.to_datetime(df["date"])
df["revenue"] = df["price"] * df["quantity"] * (1 - df["discount"])
df.head()

這裡沒有讀外部檔案,方便你直接跟著跑。 真實專案裡,df 可以來自 CSV、Parquet、資料庫,或前一篇介紹過的 DuckDB 查詢結果。 接著放一個快速摘要:

summary = {
    "orders": len(df),
    "revenue": int(df["revenue"].sum()),
    "cities": df["city"].nunique(),
    "products": df["product"].nunique(),
}
summary

你可以先確認資料長得合理,再開始加互動。

六. 加上 UI 元件:讓資料探索不只靠改程式碼
#

marimo 內建一組 UI 元件。 先做城市篩選:

city_picker = mo.ui.multiselect(
    options=sorted(df["city"].unique()),
    value=sorted(df["city"].unique()),
    label="城市",
)
city_picker

再做類別篩選:

category_picker = mo.ui.dropdown(
    options=["all"] + sorted(df["category"].unique()),
    value="all",
    label="商品類別",
)
category_picker

UI 元件本身是 Python 物件。 你可以用 .value 取得目前選擇:

filtered = df[df["city"].isin(city_picker.value)]
if category_picker.value != "all":
    filtered = filtered[filtered["category"] == category_picker.value]
filtered.head()

這就是 reactive notebook 的爽點。 使用者改了 dropdown 或 multiselect,filtered 會更新,依賴 filtered 的圖表也會跟著更新。 你不用自己寫 callback,也不用手動管理狀態。

七. 做一張圖:Altair 很適合搭配 marimo
#

接著用 Altair 畫每個城市的營收。

import altair as alt
revenue_by_city = (
    filtered.groupby("city", as_index=False)["revenue"]
    .sum()
    .sort_values("revenue", ascending=False)
)
chart = (
    alt.Chart(revenue_by_city)
    .mark_bar()
    .encode(
        x=alt.X("city:N", title="City"),
        y=alt.Y("revenue:Q", title="Revenue"),
        tooltip=["city", "revenue"],
    )
    .properties(width=520, height=300)
)
chart

如果你不想加 Altair,也可以先用 pandas 的簡單輸出。 但 marimo 很適合這種「資料表 + 圖表 + 文字解釋」的工作流。 再加一個月份趨勢:

monthly = filtered.assign(month=filtered["date"].dt.to_period("M").astype(str))
monthly = monthly.groupby("month", as_index=False)["revenue"].sum()
line = (
    alt.Chart(monthly)
    .mark_line(point=True)
    .encode(
        x=alt.X("month:N", title="Month"),
        y=alt.Y("revenue:Q", title="Revenue"),
        tooltip=["month", "revenue"],
    )
    .properties(width=520, height=260)
)
line

到這裡,這份 notebook 已經不只是靜態筆記。 它變成一個小型互動式資料報告。

八. 用 DuckDB 查 DataFrame:SQL 派也可以很舒服
#

如果你喜歡 SQL,可以把 DuckDB 接進來。

import duckdb
top_products = duckdb.sql(
    """
    SELECT
        product,
        category,
        COUNT(*) AS orders,
        ROUND(SUM(revenue), 2) AS revenue
    FROM filtered
    GROUP BY product, category
    ORDER BY revenue DESC
    LIMIT 5
    """
).df()
top_products

DuckDB 可以直接查目前 Python 作用域裡的 DataFrame。 也就是說,你可以用 pandas 做前處理,用 UI 做篩選,再用 SQL 做彙總。 這種混搭在資料探索時很實用。 但拍拍君建議你保持一個原則:每個 cell 只做一件清楚的事。 例如:

  • 一格建立 UI。
  • 一格套用篩選。
  • 一格做彙總。
  • 一格畫圖。
  • 一格寫解釋。 這樣依賴關係清楚,未來回頭看才不會想把昨天的自己抓去 code review。

九. Markdown 報告:把結果寫成人看得懂的故事
#

數字本身不會自動變成洞見。 你可以用 mo.md() 把摘要整理成文字:

best_city = revenue_by_city.iloc[0]["city"]
best_revenue = int(revenue_by_city.iloc[0]["revenue"])
mo.md(
    f"""
    ## 本次篩選結果
    目前篩選後共有 **{len(filtered)}** 筆訂單。
    營收最高的城市是 **{best_city}**,營收為 **{best_revenue:,}**。
    """
)

這段文字會隨著 UI 選擇一起更新。 所以報告不是「某一次執行後留下的截圖」,而是跟資料狀態綁在一起的說明。 這對內部分析很有用。 你可以把同一份 notebook 交給同事,讓他們切換城市、類別,看到對應的數字和文字。

十. 執行模式:edit、run、export 各有用途
#

開發時使用:

marimo edit sales_report.py

如果你只是想把它當成 app 跑:

marimo run sales_report.py

這時使用者會看到互動介面,但不一定要進入編輯模式。 如果想輸出成 HTML:

marimo export html sales_report.py -o sales_report.html

也可以匯出成 notebook:

marimo export ipynb sales_report.py -o sales_report.ipynb

拍拍君會建議:原始碼保留 .py,必要時才 export。 因為 .py 比較適合版本控制,也比較容易被一般開發工具理解。

十一. 跟 Jupyter、Streamlit 怎麼選?
#

這三者不是誰取代誰,而是使用情境不同。

工具 最適合 優點 要注意
Jupyter 教學、快速探索、龐大生態 大家熟、套件多、文件多 執行順序容易失真
marimo 可重現分析、互動 notebook、Git-friendly 報告 reactive、純 Python、好 review 生態還在成長
Streamlit 給使用者操作的資料 app 部署和 UI 很直覺 分析過程通常要另外整理
拍拍君自己的選法通常是:
  • 想快速試一段模型或資料處理:Jupyter 可以。
  • 想讓 notebook 長期留在 repo 裡:marimo 很適合。
  • 想做成穩定給別人用的 web tool:Streamlit 比較順。 如果你的團隊常常把 notebook 放進 Git,但 review 很痛苦,marimo 值得試。

十二. 專案化建議:不要把 notebook 寫成一坨
#

marimo 雖然比傳統 notebook 更有結構,但還是可能被寫亂。 拍拍君建議這樣整理:

marimo-demo/
├── pyproject.toml
├── sales_report.py
├── src/
│   └── sales_demo/
│       ├── __init__.py
│       ├── data.py
│       └── metrics.py
└── tests/
    └── test_metrics.py

把可測試的邏輯放進 src:

# src/sales_demo/metrics.py
import pandas as pd
def revenue_by_city(df: pd.DataFrame) -> pd.DataFrame:
    return (
        df.groupby("city", as_index=False)["revenue"]
        .sum()
        .sort_values("revenue", ascending=False)
    )

notebook 裡只負責組裝:

from sales_demo.metrics import revenue_by_city
city_revenue = revenue_by_city(filtered)
city_revenue

這樣有幾個好處:

  • 核心邏輯可以寫測試。
  • notebook 不會越長越難讀。
  • 專案日後可以改成 CLI、API 或 Streamlit app。
  • marimo 保留互動探索的角色,不需要承擔所有架構責任。 Notebook 不是不能工程化。 只是你要很早就把「可重用邏輯」和「互動報告」分開。

十三. 常見踩坑:拍拍君先幫你踩一輪
#

第一個坑:同一個變數不能在多個 cell 定義。 這是 marimo 故意的。 如果你在兩個 cell 都寫 df = …,它會提醒你。 解法是把流程拆清楚,例如:

raw_df = load_orders()
filtered_df = apply_filters(raw_df, city_picker.value)
summary_df = summarize(filtered_df)

第二個坑:不要在 cell 裡做太重的副作用。 例如每次 UI 一改,就重新呼叫昂貴 API 或寫入資料庫。 比較好的方式是把昂貴步驟獨立出來,必要時加上快取或明確按鈕。 第三個坑:UI 元件的值要從 .value 讀。

city_picker.value

不要把 UI 物件本身拿去做 pandas 篩選。 第四個坑:不要把密碼和 token 寫進 notebook。 這點跟所有開發工具一樣。 用環境變數、.env、secret manager,或至少把本機設定排除在 Git 外。

結語
#

marimo 很適合那些「一開始只是探索,後來變成大家都要用」的分析工作。 它比傳統 notebook 更在乎可重現性,也比完整 Web App 更輕。 拍拍君會把它放在這個位置:

  • 比 Jupyter 更適合進 repo。
  • 比 Streamlit 更適合保留分析過程。
  • 比純 Python script 更適合互動探索。 如果你的 notebook 常常變成神祕狀態機,試試 marimo。 它會逼你把資料依賴整理乾淨。 一開始有點嚴格,但這種嚴格很值得。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 58: 本文

相關文章

Rich + Typer:打造漂亮又好用的 Python CLI 體驗
·10 分鐘· loading · loading
Python Rich Typer Cli Command-Line Developer-Tools
Streamlit 進階:session_state、cache 與多頁 Dashboard 完全攻略
·11 分鐘· loading · loading
Python Streamlit Dashboard Data-App Cache Session-State
Python argparse 實戰:CLI 參數解析、旗標設計與 subcommands 完全攻略
·9 分鐘· loading · loading
Python Argparse Cli Command-Line Automation Developer-Tools
Python DuckDB 實戰:用 SQL 快速分析 CSV 與 Parquet
·6 分鐘· loading · loading
Python Duckdb SQL Parquet Data-Analysis Developer-Tools
Python Typer 進階:巢狀 subcommands、callback 與 CLI 架構
·9 分鐘· loading · loading
Python Typer Cli Command-Line Developer-Tools Testing
Python virtual environments 大比拼:venv vs conda vs uv 怎麼選?
·8 分鐘· loading · loading
Python Venv Conda Uv Virtual Environments Packaging