資料列數相同,不代表欄位配對正確。 Python 資料錯位診斷要分開檢查形狀、身分與欄位內容:先阻擋長度不符,再以穩定識別碼對齊,最後比較允許比對的資料。zip(strict=True) 是長度防線,不是語意正確性的證明。

本文處理離線匯出檔、測試資料與報表解析結果的對照問題。所有示例都是合成資料,只在記憶體建立差異報告,不連接業務服務,也不覆寫任何原始紀錄。核心觀點來自完整閱讀的技術筆記;原筆記的個案名稱、操作環境與事件數字均未帶入。

先區分長度錯誤與等長欄位錯位

長度檢查只能發現輸入數量不一致,無法知道某個日期是否屬於同一筆紀錄。應先保留完整列,再以穩定識別碼建立對照;即使兩個陣列等長,也必須另外驗證身分與欄位的對應關係。

一種容易忽略的解析問題,是把識別碼、標題與日期各自抽成陣列,再依序組合。假如日期陣列漏掉開頭的一格,預設 zip() 會在最短輸入耗盡時結束,最後一筆可能無聲消失。若日期只是整欄循環位移,輸入長度完全相同,程式仍能產生外觀完整、內容卻錯配的結果。

官方文件指出,zip() 預設以最短輸入為界,指定 strict=True 後會在迭代發現長度不一致時拋出 ValueError。這個選項不會理解日期、標題或資料的業務意義。 而且它是惰性迭代器,只建立物件而不消費內容,並不能保證差異已被檢查。本文用 list() 完整消費小型測試輸入,讓失敗條件可以直接觀察。

如果觀察到整齊的一列偏移,應把它視為解析流程的調查線索,而不是立即宣布找到唯一根因。同樣的模式也可能來自排序不一致或匯出時間不同。保留未變更的輸入快照,逐步確認資料來源與排序契約,才有辦法區分推測與證據。

逐列解析保留關係,但仍須檢查欄位結構

逐列讀取可以保留同一列內的欄位關係,避免分別擷取欄位後再猜測配對。使用具名欄位有助於閱讀與比對,但不會自動驗證識別碼唯一性、必要欄位是否存在,或每列是否符合預期結構。

csv.DictReader 將每列映射為字典;未提供 fieldnames 時,第一列會用作欄位名稱。這適合把 uid、subject、date 作為一組資料傳入比對器,而不是讓三份清單各自經過篩選。若原始匯出已經錯位,具名欄位不會自動修正,只是讓後續驗證更明確。

不要把成功讀取 CSV 當成資料契約成立。官方文件說明,多出的欄位可放到 restkey 指定的位置,缺少的值則使用 restval。因此嚴格匯入流程應自行確認標頭集合、欄位數量、空值規則與識別碼型別。本文實驗的 CSV 是固定且已知有效的合成輸入,不把小型示範函式宣稱為通用 CSV 驗證器。

比對前也應定義日期表示法。範例刻意使用固定格式字串,只驗證相等與不相等,不處理時區、日期解析或文化設定。若實際資料包含不同時區,必須先另訂正規化規格;不能因為文字不同就認定時間不同,也不能以任意截字方式讓測試通過。

先對齊身分,再分類差異與模糊案例

比對順序應是檢查唯一性、確認兩側都有識別碼、確認身分輔助欄位一致,最後才比較目標日期。缺少紀錄與身分衝突必須分開回報,不能混入日期錯誤統計,更不能直接猜測對應關係。

本例將 uid 當作單一合成資料集內的穩定鍵,subject 作為額外防線。同一鍵但標題不同,先列為衝突,不繼續建議日期校正。標題相同不是一般化的身分證明:真實設計仍需不可重用的識別碼或明確的來源範圍。這個簡化防線只是示範「證據不足就停止」的分支。

建立字典前先檢查重複鍵也很重要。直接使用字典推導式,可能讓後面同鍵的資料蓋過前面資料,使模糊性消失於程式內部。本文選擇拋出 ValueError,沒有保留第一筆、保留最後一筆或自動合併的隱含政策。空識別碼同樣拒絕;缺少必要欄位則不應被寬容忽略。

分層結果至少包含三種訊號:只在一側出現的 missing、身分輔助欄位不同的 conflict、身分通過後的 dateMismatch。三種結果的處理責任不同。缺漏需要回查擷取範圍;衝突需要確認身分;日期差異才進入欄位診斷。整體差異筆數不是一張可以直接執行的修復清單。

驗證與重現:三組純記憶體反例

本機測試必須同時涵蓋長度不等、等長錯位與身分衝突,才能證明各防線的邊界。以下固定版本程式只使用合成資料與標準函式庫,輸出分類結果;任何預期不符會立即失敗,不會修改外部資料。

前置條件:使用 Python 3.11.15,準備獨立的本機 sandbox 資料夾,不需安裝第三方套件。此版本是重現基準,不是升級或維護版本建議。將完整程式存為 alignment_lab_20260920.py,在該資料夾執行以下指令:

python --version
python alignment_lab_20260920.py
  1. 確認長度防線。 先確認版本輸出為 Python 3.11.15,再執行程式。check1.defaultPairs 必須是 2,strictRejected 必須為 true,證明預設截短與嚴格拒絕是不同結果。
  2. 確認等長錯位。 查看同次執行的 check2。dateMismatch 必須完整列出 A、B、C,且 missing、conflict 為空。這證明嚴格配對雖接受等長陣列,鍵值比對仍能發現內容錯配。
  3. 確認身分與輸入保護。 查看 check3:缺漏是 C,衝突是 B,日期差異為空;duplicateRejected、reorderInvariant、referenceUnchanged 都要為 true。整體 passed=true 且結束碼為零才通過,任何例外或欄位不符都算失敗。
"""Synthetic in-memory alignment tests. No file, account, or network access."""
import csv
import io
import json
import sys
from copy import deepcopy


def require(condition, label):
    if not condition:
        raise AssertionError(label)


def unique_index(rows):
    result = {}
    for row in rows:
        key = row['uid']
        if not key or key in result:
            raise ValueError('ambiguous identity')
        result[key] = row
    return result


def compare(left, right):
    a, b = unique_index(left), unique_index(right)
    result = {'missing': [], 'conflict': [], 'dateMismatch': []}
    for key in sorted(a.keys() | b.keys()):
        if key not in a or key not in b:
            result['missing'].append(key)
        elif a[key]['subject'] != b[key]['subject']:
            result['conflict'].append(key)
        elif a[key]['date'] != b[key]['date']:
            result['dateMismatch'].append(key)
    return result


def main():
    require(sys.version_info[:3] == (3, 11, 15), 'use Python 3.11.15')
    reference = list(csv.DictReader(io.StringIO(
        'uid,subject,date\n'
        'A,Alpha,2026-01-10\n'
        'B,Beta,2026-01-20\n'
        'C,Gamma,2026-01-30\n')))
    original = deepcopy(reference)
    short = [r['date'] for r in reference][:-1]
    default_pairs = list(zip(reference, short))
    rejected = False
    try:
        list(zip(reference, short, strict=True))
    except ValueError:
        rejected = True
    require(len(default_pairs) == 2 and rejected, 'length check')
    shifted = deepcopy(reference)
    dates = [r['date'] for r in reference]
    for row, date in zip(shifted, dates[1:] + dates[:1], strict=True):
        row['date'] = date
    diagnosis = compare(shifted, reference)
    require(diagnosis == {'missing': [], 'conflict': [], 'dateMismatch': ['A', 'B', 'C']}, 'equal-length shift')
    adversarial = deepcopy(reference)
    adversarial[1]['subject'] = 'Different'
    adversarial[1]['date'] = '2026-02-01'
    del adversarial[2]
    guarded = compare(adversarial, reference)
    duplicate_rejected = False
    try:
        compare(reference + [deepcopy(reference[0])], reference)
    except ValueError:
        duplicate_rejected = True
    reordered = compare(list(reversed(reference)), reference)
    require(guarded == {'missing': ['C'], 'conflict': ['B'], 'dateMismatch': []}, 'guard categories')
    require(duplicate_rejected, 'duplicate must fail')
    require(all(not v for v in reordered.values()), 'order independence')
    require(reference == original, 'reference unchanged')
    print(json.dumps({'python': sys.version.split()[0], 'passed': True,
        'check1': {'defaultPairs': len(default_pairs), 'strictRejected': rejected},
        'check2': diagnosis,
        'check3': {'guarded': guarded, 'duplicateRejected': duplicate_rejected,
                   'reorderInvariant': True, 'referenceUnchanged': True}}, indent=2))


if __name__ == '__main__':
    main()

排程環境實際執行結果: Python 3.11.15、結束碼 0、passed=true;上述三組檢查均符合預期。此結果只證明合成資料上的程式行為,不代表任一外部資料集已經通過對照,也不是效能測試。

程式故意使用會拋出例外的 require(),不依賴可被最佳化模式移除的 assert。測試中的日期位移、移除合成列與重複合成鍵都只發生於記憶體副本。對照資料最後仍須與最初副本相等,避免驗證程式自己改壞參考輸入,造成「兩邊一起變所以相等」的假通過。

下游檢查要追蹤舊鍵,而不是立即覆寫

若日期曾參與下游複合鍵,修正日期可能改變資料的身分判定。應先盤點哪些輸出仍保存舊鍵與舊值,再建立只讀差異報告;本文的診斷結果不能直接授權覆寫,也不能把零差異當成所有下游都已修復。

來源欄位變動之後,先前匯出的報表、索引或測試快照仍可能使用舊值。若下游以識別碼加日期判定是否已存在,新的日期可能被解讀為新的項目。問題不一定是新增邏輯本身,而是身分契約把可變欄位當成不可變的鍵。把這個依賴畫清楚,比追加一個忽略重複的條件更重要。

建立消費端清單時,逐一記錄比對鍵、輸入版本、欄位轉換與驗收規則。對無法唯一對應的資料,保留衝突狀態;對缺少的資料,不應自行補造日期。若對照來源本身也不可靠,停止推導校正值,先找另一份經確認的證據。本文沒有提供更新資料庫或批次改寫報表的操作。

驗收應區分已成功配對的範圍與未解決範圍。只有把缺漏和衝突另列,才不會用「可配對項目日期差異為零」掩飾大量未比對項目。相同地,標題完全一致不代表日期一定可信;它只是進入日期比對的條件,而非終點。驗證報告需要保留這種條件式語意。

官方依據與實作收尾清單

官方文件支持的是函式與解析器的行為,資料身分政策則需要由應用契約補足。收尾時應確認來源可追溯、差異分類完整、原始輸入未變更,並將測試通過的範圍寫清楚,避免把局部證據擴大成全面正確。

本次實際開啟並查核下列官方文件,兩種語言共用同一組事實。線上 Python 3.11 文件可能隨維護版本更新;本文的可執行證據仍固定於 Python 3.11.15,沒有宣稱在其他版本重跑過。

交付前檢查: 是否保留整列關係?是否拒絕重複識別碼?是否將缺漏、衝突與日期差異分開?是否測過等長錯位的反例?是否確認重排不影響以鍵比對的結果?是否保留輸入不變證據與下游鍵值依賴?

結論不是把所有配對換成一個選項,而是建立分層防線:長度驗證抓形狀錯誤,鍵值對齊抓對應關係,欄位比較抓內容差異。先在本機執行完整實驗,再把這三層檢查寫進自己的離線資料契約。