CSVはExcel、Python、データベース、CAD、部品表の間で使いやすい交換形式です。しかし、拡張子が.csvで同じでも、文字コード、区切り文字、改行、引用符、NULLの表現が異なれば別の形式として扱う必要があります。
「表計算ソフトで開けた」ことと、「同じデータとして再処理できる」ことは同義ではありません。この記事では、CSVを単なる文字列の集合ではなく、dialectとschemaを持つデータ契約として設計します。
CSVで決めなければならない2種類の契約
CSV連携では、ファイルの読み方と、各列の意味を分けます。
| 契約 | 決める内容 | 例 |
|---|---|---|
| dialect | 文字コード、区切り文字、改行、引用符、header | UTF-8、カンマ、CRLF、"、headerあり |
| schema | 列名、型、必須条件、NULL、桁数、単位 | asset_idは文字列で必須、length_mmは十進数 |
RFC 4180は一般的なCSV形式とtext/csv media typeを文書化していますが、Informational RFCであり、CSVのすべての実装を統一する規格ではありません。実際の連携では、RFCを基礎にしつつ自分たちの交換仕様を狭く固定します。
最小のCSV例
設計部品を交換するCSVを考えます。
asset_id,name,length_mm,active,note
000042,"Bracket, left",125.50,true,"line 1
line 2"
000043,Shaft,,false,"He said ""OK"""
この例にはCSVで壊れやすい要素が含まれます。
000042は数値ではなく、先頭ゼロを持つIDBracket, leftはカンマを含むため引用符が必要noteは改行を含むため引用符が必要- 文字列内の
"は""として表現 length_mmの空cellは、schemaでNULLと定義trueとfalseは文字列として格納され、取込時にbooleanへ変換
CSV自体には数値、boolean、NULLという型がありません。readerが返すcellは基本的に文字列であり、schemaに従って型変換して初めて業務データになります。
区切り文字・引用符・改行を固定する
RFC 4180では、recordを改行で区切り、fieldをカンマで区切ります。カンマ、改行、二重引用符を含むfieldは二重引用符で囲み、field内の二重引用符は2個重ねます。
ただし、現実にはセミコロン区切り、タブ区切り、LF改行なども使われます。W3CのTabular Data Modelは、delimiter、encoding、quote character、line terminator、header数などをdialectとして記述できるモデルを定めています。
機械連携用CSVでは、少なくとも次を固定します。
media_type: text/csv
encoding: utf-8
bom:
input: optional
output: absent
delimiter: ","
quote_character: '"'
double_quote_escape: true
line_terminator: CRLF
header: required
trim_whitespace: false
空白を自動で削除するかも契約です。" A "と"A"を同一視すると、コードや名称を意図せず変更する場合があります。正規化する列だけをschemaで指定します。
UTF-8とBOMを分けて扱う
機械間交換ではUTF-8へ統一すると扱いやすくなります。ただし、UTF-8の先頭にBOMと呼ばれる3byteが付いたファイルもあります。
Python公式資料では、utf-8-sig codecは入力先頭のUTF-8 BOMがあれば読み飛ばし、出力時にはBOMを書きます。通常のUTF-8にはbyte orderがないためBOMは必須ではありません。
実務では、次のように入出力を分けられます。
- 受信:通常のUTF-8とUTF-8 BOM付きの両方を
utf-8-sigで受ける - システム向け出力:UTF-8 BOMなしへ統一する
- 表計算ソフト向け出力:利用環境が要求する場合だけBOM付きprofileを別にする
文字コードを自動推測して処理を続けると、誤判定しても成功扱いになる危険があります。許可するencodingを限定し、decodeできないfileは隔離します。Shift_JISなどを受ける必要がある場合も、接続先ごとの明示的なprofileにします。
headerと列順をschemaで検証する
headerを見ただけで柔軟に取り込む設計は便利ですが、列名の誤字、重複、未知列を見逃しやすくなります。
columns:
- name: asset_id
type: string
required: true
pattern: "^[0-9]{6}$"
- name: name
type: string
required: true
min_length: 1
- name: length_mm
type: decimal
required: false
null_values: [""]
minimum: 0
- name: active
type: boolean
required: true
true_values: ["true"]
false_values: ["false"]
- name: note
type: string
required: false
W3C CSVWでは、列ごとにdatatype、format、required、nullなどのメタデータを定義できます。独自YAMLやJSON Schema風のデータ辞書でも構いませんが、少なくとも次を決めます。
- header名と列順を固定するか
- 未知列を拒否するか無視するか
- 空cell、空文字、NULL記号の意味
- 数値の小数点、桁数、範囲、単位
- 日時形式とtimezone
- booleanとして許可する文字列
- IDを数値へ変換しないこと
NA、NULL、-を一律に欠損扱いすると、実データと衝突する場合があります。NULL表現は列単位で決め、空cellと文字列"NULL"を無条件に同一視しません。
Pythonでdialectとschemaを固定して読む
Pythonのcsv moduleを使う場合、fileはnewline=""で開きます。公式ドキュメントでは、これを省略すると引用符内の改行が正しく扱われない場合や、書込時に余分な改行文字が入る場合があると説明されています。
次の例はdialectを推測せず、header、field数、型を明示的に検証します。
import csv
from decimal import Decimal, InvalidOperation
from pathlib import Path
EXPECTED_HEADER = [
"asset_id", "name", "length_mm", "active", "note"
]
def parse_decimal_or_none(value: str) -> Decimal | None:
if value == "":
return None
try:
result = Decimal(value)
except InvalidOperation as exc:
raise ValueError("length_mmが十進数ではありません") from exc
if not result.is_finite() or result < 0:
raise ValueError("length_mmは有限の0以上が必要です")
return result
def parse_boolean(value: str) -> bool:
if value == "true":
return True
if value == "false":
return False
raise ValueError("activeはtrueまたはfalseが必要です")
def read_assets_csv(path: Path) -> list[dict]:
records = []
with path.open(
"r",
encoding="utf-8-sig",
newline="",
) as file:
reader = csv.reader(
file,
delimiter=",",
quotechar='"',
doublequote=True,
strict=True,
)
try:
header = next(reader)
except StopIteration as exc:
raise ValueError("CSVが空です") from exc
if header != EXPECTED_HEADER:
raise ValueError(f"headerが不正です: {header}")
if len(set(header)) != len(header):
raise ValueError("header名が重複しています")
for row in reader:
line_number = reader.line_num
if len(row) != len(header):
raise ValueError(
f"{line_number}行目のfield数が不正です"
)
raw = dict(zip(header, row, strict=True))
asset_id = raw["asset_id"]
if len(asset_id) != 6 or not asset_id.isascii() or not asset_id.isdigit():
raise ValueError(
f"{line_number}行目のasset_idが不正です"
)
if not raw["name"]:
raise ValueError(
f"{line_number}行目のnameが空です"
)
records.append({
"asset_id": asset_id,
"name": raw["name"],
"length_mm": parse_decimal_or_none(raw["length_mm"]),
"active": parse_boolean(raw["active"]),
"note": raw["note"],
})
return records
csv.Snifferで形式を推測する機能もありますが、正式な連携では推測結果を契約の代わりにしません。送信元ごとのdialectを設定として保持し、想定外の形式は明示的に失敗させます。
正常系と異常系を分ける
| 入力 | 判定 |
|---|---|
| カンマを引用符で囲んだfield | 正常 |
| 引用符内の改行 | 正常 |
| UTF-8 BOM付きの先頭header | utf-8-sigで正常化 |
asset_idが000042 | 文字列のまま保持 |
| field数がheaderと不一致 | 拒否 |
| 二重引用符のescapeが不正 | 拒否 |
| decodeできないbyte列 | 拒否・隔離 |
数値列がNaNや負数 | schema違反として拒否 |
boolean列が1やTRUE | 契約外なら拒否 |
| headerの誤字・重複 | 拒否 |
エラー時にはfile名だけでなく、物理行番号、列名、受信値、期待規則を記録します。ただし、個人情報や機密値をそのままlogへ出さないようにします。
冪等性と再実行を設計する
CSV取込は、通信切断や処理失敗で同じfileが再投入されます。次の2段階で重複を防ぎます。
- file単位:raw byte列のSHA-256と取込profile versionを記録する
- record単位:
asset_idなどの安定した業務keyでUPSERTする
file hashだけでは、内容を修正した再送は別fileになります。recordの同一性は別途IDで判断します。取込処理は、全行を検証してstaging tableへ保存し、問題がなければ本tableと取込履歴を同じtransactionで確定する方法が安全です。
変換規則を変更した場合に備え、schema_version、parser_version、元fileのhashを残します。再実行時は元fileから同じ規則で再生成できるようにします。
表計算ソフト向けCSVのセキュリティ
外部入力をCSVへ出力し、ExcelやLibreOfficeで開く場合はformula injectionへ注意します。OWASPは、=、+、-、@などで始まるcellが数式として解釈される可能性を説明しています。
引用符で囲むだけでは、表計算ソフトによる数式評価を防げません。対策は利用先に応じて決めます。
- 信頼できない値を表計算ソフト向けexportへ含めるか確認する
- 数式開始文字を検出し、拒否または専用のescapeを行う
- escape後の値が原本と異なることを明示する
- 機械連携用の正本CSVと、人が開く安全化済みCSVを分ける
- CSVを開く利用者の権限を必要最小限にする
安全化のために先頭へ文字を追加するとデータそのものが変わります。原本を上書きせず、用途別の出力profileとして管理します。
CAD・DB・APIへ再利用する
CAD属性、部品表、検査値、計算条件をCSVで交換する場合も、同じ契約を利用できます。
- 図面番号や部品番号は文字列として保持する
- 寸法値と単位を別列にするか、列schemaで単位を固定する
- revisionとrecord IDを分離する
- 親子関係は親ID・子ID・数量の列で表す
- 削除や無効化を空行で表さず、状態列や別の差分仕様を設ける
- APIやDBへ渡す前に型変換と参照整合性を検証する
CSVのdialectとschemaをmachine-readableな設定として保存すれば、Python reader、DB staging table、API変換、テストデータ、データ辞書へ同じ定義を展開できます。
まとめ
CSV連携では、拡張子や見た目ではなく、byte列から業務データへ変換する規則を契約にします。
- dialectと列schemaを分けて定義する
- encoding、BOM、delimiter、改行、引用符、headerを固定する
- カンマ・改行・引用符を含むfieldは正しくquoteする
- CSVのcellを型付きデータだと思い込まない
- IDの先頭ゼロを保持し、数値へ自動変換しない
- NULL、空文字、boolean、日時、単位を列ごとに決める
- Pythonでは
newline=""と明示的なencodingを使う - 形式推測に依存せず、想定外のdialectを拒否する
- file hashとrecord IDの両方で再実行へ備える
- 表計算ソフト向けexportではformula injectionを考慮する
この契約があれば、Excelで見える表を、API・DB・Python・CADでも同じ意味を保つ再利用可能なデータ資産へ変えられます。

