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

Streamlit Data Editor 實戰:可編輯表格、上傳驗證與 CSV 匯入匯出

·8 分鐘· loading · loading · ·
Python Streamlit Data-Editor CSV Validation Developer-Tools
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 110: 本文

featured

Streamlit 做資料 App 很快。 可是需求從「看表格」變成「讓同事修表格」時,事情就沒那麼天真了。 你不能只把 DataFrame 丟進 st.data_editor(),然後期待世界和平。使用者會上傳奇怪 CSV、把數字改成空白、打錯狀態、刪掉不該刪的列,最後問你為什麼下載檔不能匯入下一個系統。 今天拍拍君要做的是一個務實的內部資料修正工具:

  • 上傳 CSV
  • 檢查欄位
  • st.data_editor 編輯資料
  • 即時顯示錯誤
  • 匯出乾淨 CSV 這篇不重講 Streamlit 入門。如果你還不熟基本元件,可以先看 Streamlit 入門;如果想整理 session state、表單與多頁架構,可以看 Streamlit 進階。 如果你要做的是資料庫 CRUD 小後台,請看 Streamlit + SQLModel 實戰。今天這篇專心處理「CSV 進來、人類修正、CSV 出去」這條工作流。

一. 前言:可編輯表格不是 UI 魔法
#

st.data_editor 很可愛:

edited_df = st.data_editor(df)

一行就有可編輯表格。 但真實工具需要更多邊界:

  1. 輸入資料要標準化。
  2. 欄位要有明確契約。
  3. 不該改的欄位要鎖住。
  4. 可選值要用選單限制。
  5. 匯出前要再驗證一次。 拍拍君覺得 st.data_editor 最適合的不是大型正式後台,而是內部資料修正、人工審核 queue、labeling prototype、營運資料補欄位這種「需要人類判斷,但不值得先做完整產品」的地方。

二. 安裝與範例資料
#

先建立專案:

uv init streamlit-editor-lab
cd streamlit-editor-lab
uv add streamlit pandas

或用 pip:

python -m venv .venv
source .venv/bin/activate
pip install streamlit pandas

專案結構:

streamlit-editor-lab/
├── app.py
└── sample_orders.csv

範例 CSV:

order_id,customer,status,quantity,unit_price,priority,note
O-1001,拍拍醬,pending,2,120,normal,needs invoice
O-1002,chatPTT,approved,1,560,high,
O-1003,小派,rejected,3,80,low,address mismatch

跑起來:

uv run streamlit run app.py

三. 先定義資料契約
#

做可編輯表格前,先定義這份資料應該長什麼樣子。

from dataclasses import dataclass


@dataclass(frozen=True)
class ColumnSpec:
    name: str
    label: str
    required: bool = True
    editable: bool = True


COLUMNS = [
    ColumnSpec("order_id", "訂單 ID"),
    ColumnSpec("customer", "客戶"),
    ColumnSpec("status", "狀態"),
    ColumnSpec("quantity", "數量"),
    ColumnSpec("unit_price", "單價"),
    ColumnSpec("priority", "優先度"),
    ColumnSpec("note", "備註", required=False),
    ColumnSpec("source", "來源", required=False, editable=False),
]

ALL_COLUMNS = [col.name for col in COLUMNS]
REQUIRED_COLUMNS = [col.name for col in COLUMNS if col.required]
DISABLED_COLUMNS = [col.name for col in COLUMNS if not col.editable]

STATUS_OPTIONS = ["pending", "approved", "rejected"]
PRIORITY_OPTIONS = ["low", "normal", "high"]

這段有點儀式感,但很值得。欄位契約會同時用在上傳檢查、editor 顯示、不可編輯欄位、匯出欄位順序。規則集中管理,後面才不會散落一堆神秘字串。

四. 讀取與檢查 CSV
#

st.file_uploader(type=["csv"]) 可以讓使用者少選錯檔案,但它不是安全保證。真正的欄位檢查還是要自己做。

from io import StringIO

import pandas as pd
import streamlit as st


def load_csv(uploaded_file) -> pd.DataFrame:
    if uploaded_file is None:
        return pd.read_csv("sample_orders.csv")

    try:
        return pd.read_csv(uploaded_file)
    except UnicodeDecodeError:
        uploaded_file.seek(0)
        text = uploaded_file.read().decode("utf-8-sig")
        return pd.read_csv(StringIO(text))


def validate_columns(df: pd.DataFrame) -> list[str]:
    errors = []

    missing = [col for col in REQUIRED_COLUMNS if col not in df.columns]
    if missing:
        errors.append(f"缺少必要欄位:{', '.join(missing)}")

    duplicated = df.columns[df.columns.duplicated()].tolist()
    if duplicated:
        errors.append(f"欄位名稱重複:{', '.join(duplicated)}")

    return errors

如果必要欄位不存在,後面 editor 再漂亮也沒用。先停下來,清楚告訴使用者 CSV 少了什麼。

五. 型別標準化
#

Data editor 會依照資料型別決定欄位行為。若同一欄混了數字、字串、空白,編輯體驗會很難預期。 先整理:

def normalize_orders(df: pd.DataFrame) -> pd.DataFrame:
    normalized = df.copy()

    for col in ALL_COLUMNS:
        if col not in normalized.columns:
            normalized[col] = "upload" if col == "source" else ""

    normalized = normalized[ALL_COLUMNS]
    normalized["order_id"] = normalized["order_id"].astype("string").str.strip()
    normalized["customer"] = normalized["customer"].astype("string").str.strip()
    normalized["status"] = normalized["status"].astype("string").str.strip()
    normalized["priority"] = normalized["priority"].astype("string").str.strip()
    normalized["note"] = normalized["note"].fillna("").astype("string")
    normalized["source"] = normalized["source"].fillna("upload").astype("string")

    normalized["quantity"] = pd.to_numeric(
        normalized["quantity"],
        errors="coerce",
    )

    normalized["unit_price"] = pd.to_numeric(
        normalized["unit_price"],
        errors="coerce",
    )

    return normalized

errors="coerce" 不是偷偷吞錯,而是把壞資料轉成可檢查的缺值。資料清理最討厭的是同一種錯誤有十種形狀。先把形狀壓扁,後面才好驗證。

六. column_config:降低輸入錯誤
#

column_config 可以設定欄位名稱、數字範圍、選單、格式與必填狀態。

def build_column_config() -> dict:
    return {
        "order_id": st.column_config.TextColumn(
            "訂單 ID",
            help="唯一識別碼;新增資料時必須填寫。",
            required=True,
        ),
        "customer": st.column_config.TextColumn(
            "客戶",
            required=True,
            max_chars=50,
        ),
        "status": st.column_config.SelectboxColumn(
            "狀態",
            options=STATUS_OPTIONS,
            required=True,
        ),
        "quantity": st.column_config.NumberColumn(
            "數量",
            min_value=1,
            max_value=999,
            step=1,
            format="%d",
            required=True,
        ),
        "unit_price": st.column_config.NumberColumn(
            "單價",
            min_value=0.0,
            step=1.0,
            format="$ %.2f",
            required=True,
        ),
        "priority": st.column_config.SelectboxColumn(
            "優先度",
            options=PRIORITY_OPTIONS,
            required=True,
        ),
        "note": st.column_config.TextColumn("備註", max_chars=200),
        "source": st.column_config.TextColumn(
            "來源",
            help="既有資料為 upload;新增列會標記為 manual。",
            default="manual",
        ),
    }

UI 限制不是後端驗證。它只是讓使用者比較不容易輸入壞資料。真正匯出前還是要驗一次。拍拍君的原則是:UI 負責引導,validation 負責把關。

七. 第一版 App
#

把上傳、欄位檢查、標準化和 editor 接起來:

st.set_page_config(page_title="Orders Editor", layout="wide")

st.title("Orders Editor")
st.caption("上傳 CSV、修正欄位、匯出乾淨資料。")

uploaded_file = st.file_uploader(
    "上傳 orders CSV",
    type=["csv"],
    max_upload_size=20,
)

raw_df = load_csv(uploaded_file)
column_errors = validate_columns(raw_df)

if column_errors:
    st.error("CSV 欄位不符合預期")
    for error in column_errors:
        st.write(f"- {error}")
    st.stop()

orders_df = normalize_orders(raw_df)

edited_df = st.data_editor(
    orders_df,
    key="orders_editor",
    column_config=build_column_config(),
    column_order=ALL_COLUMNS,
    disabled=DISABLED_COLUMNS,
    hide_index=True,
    num_rows="dynamic",
    width="stretch",
)

幾個重點:

  • key 讓 widget 身分穩定。
  • disabled 鎖住 source 這類系統欄位;新增列則由 column default 填入 manual
  • hide_index=True 避免 pandas index 變成使用者困惑來源。
  • num_rows="dynamic" 允許新增與刪除列。
  • width="stretch" 是新版 API 風格。 如果你的資料不允許新增或刪除列,就不要開 dynamic。資料工具裡的自由度,常常就是未來的 debug 工作量。

八. row-level validation:講清楚哪裡錯
#

不要只說「資料格式錯誤」。使用者需要知道哪一列、哪一欄、為什麼錯。

def validate_rows(df: pd.DataFrame) -> pd.DataFrame:
    issues = []
    normalized_ids = df["order_id"].astype("string").str.strip()
    duplicated_ids = normalized_ids.duplicated(keep=False)

    def is_blank(value) -> bool:
        return pd.isna(value) or not str(value).strip()

    for row_number, row in df.reset_index(drop=True).iterrows():
        display_row = row_number + 1

        if is_blank(row["order_id"]):
            issues.append({"row": display_row, "column": "order_id", "message": "訂單 ID 不能空白"})
        elif duplicated_ids.iloc[row_number]:
            issues.append({"row": display_row, "column": "order_id", "message": "訂單 ID 重複"})

        if is_blank(row["customer"]):
            issues.append({"row": display_row, "column": "customer", "message": "客戶不能空白"})

        if is_blank(row["status"]) or str(row["status"]) not in STATUS_OPTIONS:
            issues.append({"row": display_row, "column": "status", "message": "狀態不在允許清單"})

        if (
            pd.isna(row["quantity"])
            or row["quantity"] < 1
            or not float(row["quantity"]).is_integer()
        ):
            issues.append({"row": display_row, "column": "quantity", "message": "數量必須是大於 0 的整數"})

        if pd.isna(row["unit_price"]) or row["unit_price"] < 0:
            issues.append({"row": display_row, "column": "unit_price", "message": "單價不能小於 0"})

        if is_blank(row["priority"]) or str(row["priority"]) not in PRIORITY_OPTIONS:
            issues.append({"row": display_row, "column": "priority", "message": "優先度不在允許清單"})

    return pd.DataFrame(issues, columns=["row", "column", "message"])

顯示錯誤:

issues_df = validate_rows(edited_df)

if issues_df.empty:
    st.success("資料看起來 OK,可以匯出。")
else:
    st.warning(f"目前有 {len(issues_df)} 個問題需要修正。")
    st.dataframe(issues_df, hide_index=True, width="stretch")

「第 7 列 quantity 必須大於 0」才是人可以處理的訊息。錯誤訊息清楚,工具就已經贏一半。

九. 摘要指標:讓使用者知道目前狀態
#

加一組 metrics,讓使用者修改時能快速看到資料狀態。

def summarize_orders(df: pd.DataFrame) -> dict:
    return {
        "rows": len(df),
        "approved": int((df["status"] == "approved").sum()),
        "pending": int((df["status"] == "pending").sum()),
        "total_amount": float(
            (df["quantity"].fillna(0) * df["unit_price"].fillna(0)).sum()
        ),
    }


summary = summarize_orders(edited_df)

col1, col2, col3, col4 = st.columns(4)
col1.metric("列數", summary["rows"])
col2.metric("Approved", summary["approved"])
col3.metric("Pending", summary["pending"])
col4.metric("總金額", f"${summary['total_amount']:,.2f}")

這不是裝飾。使用者新增一列、刪掉一列、改狀態時,摘要跟著變,他們比較容易發現「咦,總金額怎麼突然變十倍」。

十. 匯出 CSV:不要直接丟出 editor 結果
#

匯出前先整理欄位與型別:

def prepare_export(df: pd.DataFrame) -> pd.DataFrame:
    export_df = df.copy()
    export_df = export_df[ALL_COLUMNS]

    export_df["order_id"] = export_df["order_id"].astype("string").str.strip()
    export_df["customer"] = export_df["customer"].astype("string").str.strip()
    export_df["status"] = export_df["status"].astype("string").str.strip()
    export_df["priority"] = export_df["priority"].astype("string").str.strip()
    export_df["note"] = export_df["note"].fillna("").astype("string")
    export_df["source"] = export_df["source"].fillna("manual").astype("string")
    export_df["quantity"] = export_df["quantity"].astype("Int64")
    export_df["unit_price"] = export_df["unit_price"].round(2)

    return export_df


def to_csv_bytes(df: pd.DataFrame) -> bytes:
    return df.to_csv(index=False).encode("utf-8-sig")

utf-8-sig 對某些 Excel 環境比較友善。不是唯一答案,但很常見。 下載按鈕:

download_data = b""
if issues_df.empty:
    export_df = prepare_export(edited_df)
    download_data = to_csv_bytes(export_df)

st.download_button(
    "下載 cleaned_orders.csv",
    data=download_data,
    file_name="cleaned_orders.csv",
    mime="text/csv",
    disabled=not issues_df.empty,
)

如果還有錯,就先禁用下載。你也可以允許下載 draft 版,但拍拍君通常不建議,因為錯誤 CSV 一旦被轉交出去,你就要開始玩偵探遊戲。

十一. 完整 App 骨架
#

實作時,把上面的函式依序放進 app.py,主流程維持這個順序:上傳 CSV、檢查欄位、標準化型別、顯示 st.data_editor、做 row-level validation、顯示摘要,最後只在沒有錯誤時開放下載。 這個版本不是企業級系統,但已經有固定欄位契約、CSV 上傳、可編輯表格、欄位型別設定、row-level validation、摘要資訊、匯出前整理與錯誤阻擋。對很多內部資料修正任務來說,這就夠實用了。

十二. 常見踩雷
#

12.1 混合型別讓欄位不能編輯
#

不要把原始 CSV 直接丟進 editor。先 normalize,讓每欄型別穩定。

12.2 以為 type=["csv"] 就安全
#

這只是使用者體驗,不是安全邊界。你仍然要限制大小、檢查欄位、處理 encoding,並避免把上傳檔直接寫到永久路徑。部署與上傳檔案問題可參考 Streamlit 部署實戰

12.3 讓所有欄位都可編輯
#

像來源系統、建立時間、審核者 ID,通常應該只讀。若允許新增列,則要替唯讀欄位設定合理的 default;使用者不該改的,就不要讓它看起來可以改。

12.4 匯出時沒有固定欄位順序
#

CSV 常常會接到下一個系統。匯出前固定欄位順序:

export_df = export_df[ALL_COLUMNS]

不要相信目前 DataFrame 的自然順序。那是今天的巧合,不是明天的契約。

12.5 把 data editor 當正式後台
#

如果你需要多人同時編輯、權限、審計紀錄、交易鎖定、長期資料儲存,請回到正式後台或資料庫 workflow。Streamlit 很好,但不要逼它假裝成所有系統。

結語
#

st.data_editor 的重點不是「表格可以編輯」,而是你能不能把編輯前後的資料流程整理好。 今天我們做了 CSV 上傳、欄位檢查、型別標準化、column_config、動態列、row-level validation、摘要 metrics、匯出前整理與下載。 這套模式很適合內部工具。不是每個資料修正需求都需要開一個後台專案。有時候,一個小小的 Streamlit App,加上一點資料契約和 validation,就能讓團隊少傳十封「這個 CSV 又壞了」的訊息。 拍拍君覺得這就是好工具的樣子:不搶戲,但把麻煩收掉。

延伸閱讀
#

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

相關文章

Streamlit + SQLModel 實戰:做一個本機 CRUD 小後台
·9 分鐘· loading · loading
Python Streamlit SQLModel SQLite CRUD Developer-Tools
Textual Form Wizard 實戰:多步驟表單、Validation 與狀態切換
·5 分鐘· loading · loading
Python Textual TUI Forms Validation Developer-Tools
FastAPI + Streamlit 實戰:API 後端與互動前端分工
·9 分鐘· loading · loading
Python FastAPI Streamlit Api Frontend Developer-Tools
Streamlit Fragments + Dialogs:局部 Rerun、即時面板與 Modal Workflow
·9 分鐘· loading · loading
Python Streamlit Fragments Dialogs Rerun Session State Developer-Tools
uv 管理 Python 版本:Install、Find、Pin、Upgrade 與直譯器選擇
·9 分鐘· loading · loading
Python Uv Python Versions Interpreter Virtualenv Developer-Tools
uv + GitHub Actions 實戰:Lockfile、Cache 與可重現 CI
·5 分鐘· loading · loading
Python Uv GitHub Actions Ci Lockfile Cache Developer-Tools