CSVの構文が正しくても表計算では壊れる
CSVは、行とfieldを区切って表形式のdataを交換するための単純な形式です。しかし、CSVをExcelやGoogle Sheetsで開くと、各cellは単なる文字列として保持されるとは限りません。先頭が=の文字列は数式、00127は数値、2026-10-28は日付として解釈される可能性があります。
この違いは二つの問題を生みます。一つは、部品IDの先頭ゼロ、長い管理番号、日時表記などが変わる「型崩れ」です。もう一つは、外部入力が数式として実行される「CSV Injection」または「Formula Injection」です。
CSVの引用符やdelimiterを正しく処理することと、表計算softwareで安全に開けることは別の契約です。対策の中心は、原本data、machine向けCSV、表計算向けexportを分離し、fieldごとの型と出力policyを先に決めることです。
CSVにはcellの型が入っていない
RFC 4180は、record、field、comma、改行、double quoteなどCSVの基本構造を文書化しています。一方、各fieldがID、数値、日付、真偽値、NULLのどれであるかはCSVだけでは表せません。
次の1行は構文上は単純ですが、表計算softwareがどう解釈するかはfieldごとに異なります。
part_id,description,quantity,updated_at
00127,=1+1,-2.5,2026-10-28T01:30:00Z
00127は数値化されると127になる=1+1は文字列ではなく数式になる可能性がある-2.5は負の数値として扱うのが正しい- UTC日時はlocaleや表示形式によって別表記へ変換される可能性がある
先頭文字だけを見てすべての-を危険扱いすると、正当な負数まで文字列へ変わります。逆に、すべてのfieldを文字列として書き出すと、数量や価格を計算しにくくなります。必要なのはrecord全体へ一律の処理をかけることではなく、schemaに基づいてfieldごとに処理することです。
原本と表計算向けexportを分ける
安全な構成では、DBやJSONに保持する原本dataを変更しません。出力時に、利用先ごとのprofileを適用します。
| 出力profile | 主な利用先 | 方針 |
|---|---|---|
| canonical | DB、API、再処理 | 型と元の値を保持する |
| machine CSV | ETL、Python、別system | schemaを別途共有し、値を改変しない |
| spreadsheet CSV | Excel、LibreOffice | 数式解釈と自動型変換を抑える |
| Sheets API | Google Sheets | RAWと型付きvalueを使う |
表計算向けにsingle quoteを付けた値を原本へ書き戻してはいけません。次のexportで再びsingle quoteを付けると、'=1+1、''=1+1のように値が変わり続けます。必ずraw recordから毎回生成すれば、再実行しても同じ出力になります。
flowchart TD
A[DBまたはJSONの原本] --> B[型と必須fieldを検証]
B --> C{出力先}
C -->|machine| D[値を保ったCSV]
C -->|spreadsheet| E[数式と型変換を抑えたCSV]
C -->|Sheets API| F[RAW指定でcellへ書込]数式として解釈される入力を分離する
OWASPは、外部入力をCSVへ埋め込み、表計算softwareが数式として解釈する問題をCSV Injectionとして説明しています。代表的な危険prefixには=、+、-、@、tab、改行、null文字などがあります。
ただし、防御対象は「文字列field」です。数量のようにschema上の数値であり、Decimalとしてvalidation済みの-2.5までsingle quoteで逃がす必要はありません。処理を次の順序に分けます。
- 入力recordをschemaに従って型検証する
- ID、名称、説明などtext fieldだけを数式対策の対象にする
- 数値は許容範囲を確認し、正規化した数値文字列を出力する
- CSVのdelimiter、quote、改行はCSV libraryに処理させる
- 出力後のfileを再入力にして再度escapeしない
double quoteでcellを囲むだけでは数式対策になりません。CSV parserが引用符を外した後、cellの内容が=から始まれば、表計算softwareは数式として扱えるからです。
ID・日時・NULLの型崩れを設計する
IDと長い番号
部品ID、郵便番号、管理番号、電話番号は、数字だけで構成されていても計算対象ではありません。先頭ゼロや全桁を保持する必要があるためtext型です。Excelでは長い数値が指数表記になったり、一定桁より後が丸められたりする可能性があります。
表計算向けCSVではsingle quoteを付ける方法がありますが、これはexport用の表現です。受け手がimport操作を選べるなら、Excelの「テキストまたはCSVから」で対象columnをtextとして指定する方が、元の値を変えずに扱えます。
日時
日時はRFC 3339形式のUTC文字列など、system間の契約を固定します。ただし、表計算softwareが自動で日時へ変換すると、表示上のtimezoneや精度が変わることがあります。表示・計算が目的なら日時cellへ明示変換し、監査用timestampや外部IDとして保持するならtextとして渡します。
NULLと空文字
空のCSV fieldだけでは、NULL、空文字、未入力を区別できません。null_tokenとして予約値を決める、value_present列を追加する、sidecar schemaを添付するなど、受け手と契約します。文字列NULLを無条件に欠損扱いすると、本当にNULLという名称を持つdataを失います。
真偽値
true、false、1、0、yesを混在させず、出力値を固定します。表計算で論理値として使うならTRUEとFALSE、machine連携ならschemaが定めた表現を使います。
Pythonで表計算向けCSVを生成する
次の例は、JSONやDBから取得した部品recordを検証し、表計算向けCSVへ変換します。IDはtextとして保持し、説明文の危険prefixをsingle quoteで中和します。数量はDecimalとして検証するため、正当な負数と未検証の文字列を区別できます。
from __future__ import annotations
import csv
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Any, Iterable
FORMULA_PREFIXES = ("=", "+", "-", "@")
LEADING_CONTROLS = " \t\r\n\0"
def spreadsheet_text(value: str, *, force_text: bool = False) -> str:
if "\0" in value:
raise ValueError("NUL is not allowed in spreadsheet export")
probe = value.lstrip(LEADING_CONTROLS)
if force_text or probe.startswith(FORMULA_PREFIXES):
return "'" + value
return value
def decimal_text(value: Any) -> str:
try:
number = Decimal(str(value))
except (InvalidOperation, ValueError) as error:
raise ValueError(f"invalid decimal: {value!r}") from error
if not number.is_finite():
raise ValueError("NaN and Infinity are not allowed")
return format(number, "f")
def export_parts(rows: Iterable[dict[str, Any]], path: Path) -> None:
fieldnames = [
"part_id",
"description",
"quantity",
"unit",
"released",
"updated_at",
]
with path.open("w", encoding="utf-8-sig", newline="") as stream:
writer = csv.DictWriter(
stream,
fieldnames=fieldnames,
extrasaction="raise",
lineterminator="\r\n",
)
writer.writeheader()
for row in rows:
part_id = str(row["part_id"])
description = str(row["description"])
unit = str(row["unit"])
updated_at = str(row["updated_at"])
if unit not in {"pcs", "kg", "m"}:
raise ValueError(f"unknown unit: {unit}")
if not isinstance(row["released"], bool):
raise ValueError("released must be boolean")
writer.writerow(
{
"part_id": spreadsheet_text(part_id, force_text=True),
"description": spreadsheet_text(description),
"quantity": decimal_text(row["quantity"]),
"unit": spreadsheet_text(unit),
"released": "TRUE" if row["released"] else "FALSE",
"updated_at": spreadsheet_text(
updated_at, force_text=True
),
}
)
csv.DictWriterはcomma、double quote、改行をCSVの規則に従って処理します。spreadsheet_textは別の責務として、表計算softwareの解釈を抑えます。この二つを混同しないことが重要です。
utf-8-sigは表計算softwareとの互換性を重視した出力profileの選択です。machine向けCSVまで同じencodingへ固定する必要はありません。encoding、改行、delimiter、NULL表現はprofileのmetadataとして記録します。
Google Sheets APIではRAWを選ぶ
Google Sheets APIでcellへ直接書き込む場合、valueInputOptionが入力の解釈を決めます。USER_ENTEREDはUIへ入力した場合と同様に文字列を数式、日付、数値などへ変換します。一方、RAWは入力を解析せず保存します。
外部dataを文字列として渡す処理では、原則としてRAWを使用します。数量はJSON number、真偽値はJSON boolean、IDや日時識別子はstringとして送れば、CSVのsingle quoteに依存せず型を分けられます。数式を意図的に登録する処理だけを別interfaceにし、一般data取込と同じendpointや関数へ混ぜません。
正常系と異常系をtestする
| test input | 期待する結果 |
|---|---|
00127の部品ID | 先頭ゼロを保持したtext |
=1+1の説明 | 数式として実行されないtext |
-2.5の数量 | 負の数値として保持 |
| 16桁以上の管理番号 | 丸めずtextとして保持 |
| commaとdouble quoteを含む説明 | 1cellとして復元できる |
| 空文字とNULL | 契約した規則で区別できる |
| 未知のunit | exportを停止してerror化 |
| 同じ原本から再実行 | byte単位で同じ結果になる |
生成したCSVは、Pythonで読み戻すtestと、実際の対象softwareで開くtestの両方が必要です。Pythonで正しく復元できても、ExcelやSheetsの自動変換までは検証できません。特にID、日時、長い番号、数式prefixをfixtureとして固定します。
セキュリティと運用上の注意
CSVへaccess token、password、個人情報、非公開URLを安易に含めないようにします。CSVはmail添付、download folder、共有driveなどへcopyされやすく、DBのaccess controlから外れた後も残ります。
数式対策をしても、CSV file自体の公開範囲や保存期間は別問題です。export jobには実行者、対象条件、schema version、record件数、hash、生成時刻を記録し、本文dataとは分離したaudit logへ残します。
また、single quote方式は表計算向けの互換策であり、すべてのsoftwareで同じ表示になるとは限りません。型保持を厳密に要求する場合は、CSVではなく、型付きのXLSX、Parquet、API、Google Sheets APIなどを選びます。
DB・API・CAD連携へ再利用する
CAD属性やBOMでは、部品ID、図番、revision、数量、単位、更新日時が同じ行に並びます。見た目が数字でも、図番と数量は異なる型です。schemaでtextとnumberを分離し、表計算向けexportだけへ解釈対策を適用すれば、原本dataをAPIやDBへ再利用できます。
将来の連携では、CSV fileと一緒にschema version、column名、type、NULL規則、unit、timezone、export profileをmetadataとして管理します。これにより、Python取込ではmachine profile、手作業確認ではspreadsheet profileを選択でき、同じ原本から用途別の表現を再生成できます。
まとめ
CSVを安全に表計算へ渡すには、CSV構文のescapeだけでは足りません。数式注入、自動型変換、先頭ゼロ、長い番号、日時、NULLという「cell解釈」の契約が必要です。
原本dataを保持し、schemaでfieldの型を確定し、machine向けCSVと表計算向けCSVを分けます。文字列fieldだけに数式対策を適用し、数値は型検証して出力します。Google Sheets APIではRAWを使い、再実行時は常に原本からexportすることで、重複escapeとdata変形を防げます。
