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)
一行就有可編輯表格。 但真實工具需要更多邊界:
- 輸入資料要標準化。
- 欄位要有明確契約。
- 不該改的欄位要鎖住。
- 可選值要用選單限制。
- 匯出前要再驗證一次。
拍拍君覺得
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 又壞了」的訊息。
拍拍君覺得這就是好工具的樣子:不搶戲,但把麻煩收掉。
延伸閱讀 #
- Streamlit 官方文件:
st.data_editor - Streamlit 官方文件:
st.file_uploader - Streamlit 官方文件:
st.download_button - Streamlit 入門:用 Python 快速打造互動式資料應用
- Streamlit 進階:Session State、Cache 與多頁 Dashboard
- Streamlit + SQLModel 實戰:做一個本機 CRUD 小後台