CSVの仕様設計とは、「ファイルの形(文字コード・BOM・改行・囲み)」と「取り込むときに何をどの順で検査し、エラーをどう返すか」を表で決めることです。 CSV は広く使われている形式ですが、細部は作る側と受け取る側の取り決めに任されています。取り決めを文書にしておけば、出力する側・取り込む側・テストする人が同じ表を見て作業できます。

この記事では、CSVファイル仕様表と取込時エラーチェック一覧をテンプレートと記入例で示します。BOM の有無、Shift_JIS、CRLF と LF、項目内の改行と二重引用符については、Python の csv モジュールで実際にファイルを出力し、バイト列と読み込み結果を載せました。

確認環境:Python 3.11.15(標準ライブラリの csv、sqlite3)。2026-10-05 に実行しました。CSV の形式は RFC 4180、数式インジェクション対策は OWASP の「CSV Injection」のページを同じ日に開いて確かめています。

この記事の結論

  • CSV の仕様は2つの表で決めます。ファイルの形を決める「CSVファイル仕様表」と、取込時の検査を決める「エラーチェック一覧」です。
  • 文字コードは、表計算ソフトで開く人がいるなら BOM 付き UTF-8、プログラムどうしの受け渡しなら BOM なし UTF-8 が基本です。取り込む側は BOM の有無のどちらも受け付けるようにします。
  • 取込時の検査は形式 → 項目単体 → 相関 → DB照合の順に行い、前の段階を通った行だけ次へ進めます。
  • エラーが1行でもあれば全部を取り消すか、正常な行だけ取り込むかは、行どうしに関係があるかと利用者が直して再取込しやすいかで選びます。
  • 出力する CSV を表計算ソフトで開く想定なら、=、+、-、@ などで始まる文字列の項目に対策を入れると仕様書に書きます。

CSV仕様で決めること早見表

決めること 選択肢 出力の基本 取込の基本
文字コード UTF-8、Shift_JIS(CP932) UTF-8 仕様で1つに決める(UTF-8)。別の文字コードは形式エラー
BOM 付ける、付けない 表計算ソフトで開くなら付ける 有無のどちらも受け付ける
改行 CRLF、LF CRLF(RFC 4180 の形) CRLF と LF のどちらも受け付ける
区切り文字 カンマ、タブ カンマ 仕様で1つに決める
囲み 必要な項目だけ、全項目 全項目を二重引用符で囲む 囲みの有無のどちらも受け付ける
項目内の二重引用符 2つ重ねる 2つ重ねる("") 同じ
項目内の改行 許可する、許可しない 囲んだ項目の中で LF 項目ごとに許可するかを決める
ヘッダ行 あり、なし あり(1行目) あり。項目名と並び順が仕様と一致するか検査する
日付・日時 YYYY-MM-DD、YYYY/MM/DD ほか YYYY-MM-DD、日時は YYYY-MM-DD HH:MM:SS 1つの書式だけ受け付ける
数値 桁区切り、小数点、符号 桁区切りなし、半角 桁区切りなし、半角だけ受け付ける
値が無いとき 空文字、NULL などの文字 空(囲みだけ "") 空は「値なし」として扱う
最終行の改行 あり、なし あり 有無のどちらも受け付ける

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「CSV出力・取込の仕様設計|文字コード・BOMとエラーチェック一覧」

CSVファイル仕様表のテンプレートと記入例

ファイル全体の仕様

架空の備品予約システムの「備品一括登録 CSV」を題材にした記入例です。

No 項目 決めた内容(記入例) 補足
1 ファイル名 items_YYYYMMDD_HHMMSS.csv(出力時)。取込時はファイル名を問わない 拡張子は .csv
2 文字コード UTF-8 取込時に UTF-8 として読めなければエラー E101
3 BOM 出力は付ける。取込は有無のどちらも可 表計算ソフトで開いたとき文字化けしないようにするため
4 改行コード 出力は CRLF。取込は CRLF・LF のどちらも可 項目内の改行は LF
5 区切り文字 カンマ(,)
6 囲み文字 二重引用符(")。出力は全項目を囲む。取込は囲みなしも可
7 項目内の二重引用符 2つ重ねて表す("") RFC 4180 と同じ
8 項目内のカンマ・改行 囲んだ項目の中では文字として扱う。改行を許可するのは「備考」だけ
9 ヘッダ行 1行目に項目名。取込時は項目名と並び順が一致するか検査する 不一致はエラー E102
10 最終行の改行 出力は付ける。取込は有無のどちらも可
11 空行 取込時は読み飛ばす。行番号は空行も数える エラーの行番号を表計算ソフトの行番号と合わせるため
12 値が無いとき 空(出力は "")。文字の NULL は使わない 必須項目が空ならエラー E201
13 前後の空白 取込時に取り除く 全角の空白も取り除く
14 件数の上限 データ行 1,000 行、ファイルサイズ 5MB 超過はエラー E103
15 数式として解釈されうる項目 出力時、文字列の項目が = + - @ などで始まるときは先頭に ' を付ける 後述の対策表を参照

項目ごとの仕様

No 項目名(ヘッダ) 型 桁数・範囲 必須 書式 例 備考
1 備品コード 文字列 半角英数字とハイフン、20桁まで ○ EQ- +数字 EQ-101 ファイル内で重複不可。登録済みはエラー
2 備品名 文字列 50文字まで ○ ― プロジェクター
3 分類コード 文字列 半角英字2桁 ○ ― AV 分類マスタに登録済みのもの
4 数量 整数 1〜9999 ○ 半角数字、桁区切りなし 2
5 購入日 日付 ― ○ YYYY-MM-DD 2026-04-01
6 廃棄日 日付 ― YYYY-MM-DD 空は「廃棄していない」。購入日以降

タブ区切りのテンプレートです(表計算ソフトに貼れます)。

No  項目名(ヘッダ)    型   桁数・範囲   必須  書式  例   備考
1   備品コード   文字列 半角英数字とハイフン、20桁まで    ○   EQ-+数字  EQ-101  ファイル内で重複不可。登録済みはエラー
2   備品名 文字列 50文字まで  ○   ―   プロジェクター 
3   分類コード   文字列 半角英字2桁  ○   ―   AV  分類マスタに登録済みのもの
4   数量  整数  1〜9999  ○   半角数字、桁区切りなし 2   
5   購入日 日付  ―   ○   YYYY-MM-DD  2026-04-01  
6   廃棄日 日付  ―       YYYY-MM-DD      空は「廃棄していない」。購入日以降

RFC 4180 に書かれていること・書かれていないこと

CSV の形式を説明した文書として RFC 4180 がよく参照されます。今回開いて確かめた範囲では、次の内容が書かれています。

RFC 4180 に書かれていること 仕様書で自分たちが決めること
レコードは改行(CRLF)で区切る。最後のレコードの改行はあっても無くてもよい 取込時に LF だけの改行を受け付けるか
1行目にヘッダ行を置いてもよい ヘッダ行を置くか、項目名を検査するか
項目はカンマで区切る。空白は項目の一部として扱う 前後の空白を取り除くか
項目は二重引用符で囲んでもよい。改行・二重引用符・カンマを含む項目は囲む 全項目を囲むか、必要な項目だけ囲むか
囲んだ項目の中の二重引用符は、2つ重ねて表す ―
MIME タイプは text/csv。charset と header のパラメータを付けられる 文字コード、BOM の有無

文字コード、BOM、日付や数値の書式、値が無いときの表し方は RFC 4180 の範囲の外です。「RFC 4180 に従う」と書くだけでは決まらない項目が多いので、上の仕様表で1つずつ決めます。

BOM・文字コード・改行をバイト列で確かめる

仕様表の項目が実際のファイルでどう違うのかを、Python の csv モジュールで出力して確かめます。同じ3レコード(ヘッダ、二重引用符を含む行、項目内に改行を含む行)を4種類の設定で書き出します。

import csv, io

rows = [
    ["備品コード", "備品名", "備考"],
    ["EQ-001", "プロジェクター", '型番は"X-100"、色は黒'],
    ["EQ-002", "延長コード", "1行目\n2行目"],
]

def write(path, encoding, lineterminator):
    # newline="" を付けて、改行の変換を csv モジュールに任せる
    with open(path, "w", encoding=encoding, newline="") as f:
        csv.writer(f, lineterminator=lineterminator).writerows(rows)

write("utf8_bom_crlf.csv", "utf-8-sig", "\r\n")
write("utf8_crlf.csv", "utf-8", "\r\n")
write("utf8_lf.csv", "utf-8", "\n")
write("sjis_crlf.csv", "cp932", "\r\n")

for name in ["utf8_bom_crlf.csv", "utf8_crlf.csv", "utf8_lf.csv", "sjis_crlf.csv"]:
    data = open(name, "rb").read()
    print(f"{name}: {len(data)} バイト")
    print("  先頭16バイト:", data[:16].hex(" "))
    print("  CRLF の数:", data.count(b"\r\n"), "/ LF だけの数:", data.count(b"\n") - data.count(b"\r\n"))

print()
print("2行目のバイト列(utf8_crlf.csv):")
print(" ", open("utf8_crlf.csv", "rb").read().split(b"\r\n")[1])
print("3行目以降のバイト列(utf8_crlf.csv):")
print(" ", open("utf8_crlf.csv", "rb").read().split(b"\r\n", 2)[2])
utf8_bom_crlf.csv: 142 バイト
  先頭16バイト: ef bb bf e5 82 99 e5 93 81 e3 82 b3 e3 83 bc e3
  CRLF の数: 3 / LF だけの数: 1
utf8_crlf.csv: 139 バイト
  先頭16バイト: e5 82 99 e5 93 81 e3 82 b3 e3 83 bc e3 83 89 2c
  CRLF の数: 3 / LF だけの数: 1
utf8_lf.csv: 136 バイト
  先頭16バイト: e5 82 99 e5 93 81 e3 82 b3 e3 83 bc e3 83 89 2c
  CRLF の数: 0 / LF だけの数: 4
sjis_crlf.csv: 106 バイト
  先頭16バイト: 94 f5 95 69 83 52 81 5b 83 68 2c 94 f5 95 69 96
  CRLF の数: 3 / LF だけの数: 1

2行目のバイト列(utf8_crlf.csv):
  b'EQ-001,\xe3\x83\x97\xe3\x83\xad\xe3\x82\xb8\xe3\x82\xa7\xe3\x82\xaf\xe3\x82\xbf\xe3\x83\xbc,"\xe5\x9e\x8b\xe7\x95\xaa\xe3\x81\xaf""X-100""\xe3\x80\x81\xe8\x89\xb2\xe3\x81\xaf\xe9\xbb\x92"'
3行目以降のバイト列(utf8_crlf.csv):
  b'EQ-002,\xe5\xbb\xb6\xe9\x95\xb7\xe3\x82\xb3\xe3\x83\xbc\xe3\x83\x89,"1\xe8\xa1\x8c\xe7\x9b\xae\n2\xe8\xa1\x8c\xe7\x9b\xae"\r\n'

読み取れることは次のとおりです。

見る場所 分かること
先頭の ef bb bf BOM 付き UTF-8 は、先頭に3バイトが付く(142 バイトと 139 バイトの差)
UTF-8 と Shift_JIS のサイズ 同じ内容で 139 バイトと 106 バイト。日本語1文字が UTF-8 では3バイト、Shift_JIS では2バイト
CRLF の数と LF だけの数 CRLF を指定したファイルでも「LF だけ」が1つ残る。項目内の改行は、書き込んだ LF のまま出力される
2行目のバイト列 項目内の二重引用符は "" と2つ重なり、項目全体が二重引用符で囲まれる
3行目以降のバイト列 改行を含む項目は囲まれ、囲みの中の \n はレコードの区切りではない

読み込んで確かめる

出力した4つのファイルを、文字コードの指定を変えて読み込みます。

import csv

def read(path, encoding):
    with open(path, encoding=encoding, newline="") as f:
        return list(csv.reader(f))

def show(label, value):
    print(label, "文字数", len(value), "/ 先頭の文字 U+%04X" % ord(value[0]))

show("1) BOM付きを utf-8 で読む     :", read("utf8_bom_crlf.csv", "utf-8")[0][0])
show("2) BOM付きを utf-8-sig で読む :", read("utf8_bom_crlf.csv", "utf-8-sig")[0][0])
show("3) BOMなしを utf-8-sig で読む :", read("utf8_crlf.csv", "utf-8-sig")[0][0])
show("4) Shift_JIS を cp932 で読む  :", read("sjis_crlf.csv", "cp932")[0][0])
try:
    read("sjis_crlf.csv", "utf-8")
except UnicodeDecodeError as e:
    print("5) Shift_JIS を utf-8 で読む  :", type(e).__name__, e)
print("6) UTF-8 を cp932 で読む      :", end=" ")
try:
    print(repr(read("utf8_crlf.csv", "cp932")[0][0]))
except UnicodeDecodeError as e:
    print(type(e).__name__, e)

for name in ["utf8_crlf.csv", "utf8_lf.csv"]:
    r = read(name, "utf-8")
    print(name, "→ レコード数", len(r), "/ 項目内改行", repr(r[2][2]), "/ 引用符", repr(r[1][2]))

# DictReader でヘッダに BOM が残る例
with open("utf8_bom_crlf.csv", encoding="utf-8", newline="") as f:
    row = next(csv.DictReader(f))
    print("BOM付きを utf-8 で DictReader: '備品コード' in row →", "備品コード" in row)

# 物理的な行数とレコード数の違い
text = open("utf8_crlf.csv", encoding="utf-8", newline="").read()
print("ファイルの行数:", text.count("\n"), "/ レコード数:", len(read("utf8_crlf.csv", "utf-8")))

# cp932 に無い文字
try:
    "備品🙂".encode("cp932")
except UnicodeEncodeError as e:
    print(type(e).__name__, "/ cp932 に変換できない文字:", e.object[e.start])
1) BOM付きを utf-8 で読む     : 文字数 6 / 先頭の文字 U+FEFF
2) BOM付きを utf-8-sig で読む : 文字数 5 / 先頭の文字 U+5099
3) BOMなしを utf-8-sig で読む : 文字数 5 / 先頭の文字 U+5099
4) Shift_JIS を cp932 で読む  : 文字数 5 / 先頭の文字 U+5099
5) Shift_JIS を utf-8 で読む  : UnicodeDecodeError 'utf-8' codec can't decode byte 0x94 in position 0: invalid start byte
6) UTF-8 を cp932 で読む      : UnicodeDecodeError 'cp932' codec can't decode byte 0x89 in position 14: illegal multibyte sequence
utf8_crlf.csv → レコード数 3 / 項目内改行 '1行目\n2行目' / 引用符 '型番は"X-100"、色は黒'
utf8_lf.csv → レコード数 3 / 項目内改行 '1行目\n2行目' / 引用符 '型番は"X-100"、色は黒'
BOM付きを utf-8 で DictReader: '備品コード' in row → False
ファイルの行数: 4 / レコード数: 3
UnicodeEncodeError / cp932 に変換できない文字: 🙂
番号 結果 仕様書に書くこと
1 BOM 付きを utf-8 で読むと、最初の項目の先頭に U+FEFF が残り、5文字のはずが6文字になる 取込側は BOM を取り除いて読む
2、3 utf-8-sig は、BOM があっても無くても同じ結果になる 「BOM は有無のどちらも可」とし、Python なら utf-8-sig で読む
5、6 文字コードの指定が合わないと UnicodeDecodeError になる 形式チェックの最初で検査し、エラーコードと文言を決める
DictReader BOM が残ると、ヘッダの項目名が一致しない('備品コード' in row が False) ヘッダ行の検査より前に BOM を取り除く
行数 ファイルの行数は 4、レコード数は 3 エラーの「行番号」が何を指すかを決める(後述)
最後の行 絵文字は cp932 に変換できず UnicodeEncodeError になる Shift_JIS で出力する仕様なら、変換できない文字の扱いを決める

BOM付きUTF-8・BOMなしUTF-8・Shift_JIS の選び方

条件 選ぶもの
利用者が表計算ソフトでダブルクリックして開く BOM 付き UTF-8
プログラムやバッチどうしで受け渡す BOM なし UTF-8
受け取る側のシステムが Shift_JIS だけに対応している Shift_JIS(CP932)。変換できない文字の扱い(置き換える文字、またはエラー)を仕様に書く
取り込む側で、どちらが来るか分からない 仕様は UTF-8 に決め、BOM の有無は両方受け付ける
絵文字や多言語の文字を含みうる UTF-8

改行コードは、出力は CRLF、取込は CRLF と LF の両方を受け付ける形にしておくと、作成元の OS や道具の違いを吸収できます。

取込時エラーチェック一覧のテンプレート

検査の4つの段階

段階 検査の対象 エラーのときの動き エラーコードの帯
1. 形式 ファイル全体(文字コード、ヘッダ、件数、項目数) ファイル全体を受け付けず、以降の検査をしない(項目数の違いはその行だけ除く) E1xx
2. 項目単体 1つの項目(必須、型、桁、範囲、書式) その行のエラーをすべて集める E2xx
3. 相関 同じ行の項目どうし、ファイル内の行どうし 項目単体を通った行だけ検査する E3xx
4. DB照合 データベースの内容(マスタに存在するか、登録済みか) 相関まで通った行だけ検査する E4xx

この順にする理由は2つあります。1つは、前の段階が通っていないと後の検査が意味を持たないためです(日付として読めない値どうしの前後関係は比べられません)。もう1つは、データベースへの問い合わせを最後に回すと、形式の誤りが多いファイルで余分な問い合わせをせずに済むためです。

エラーチェック一覧(記入例)

コード 段階 検査内容 対象 エラー文言
E101 形式 文字コードが UTF-8 として読めるか ファイル 文字コードが UTF-8 ではありません。
E102 形式 ヘッダ行の項目名と並び順が仕様と一致するか ファイル ヘッダ行が仕様と一致しません。
E103 形式 データ行が上限以内か ファイル データ行が上限(1000行)を超えています。
E104 形式 行の項目数がヘッダと同じか 行 項目数が {n} 個です(正しくは 6 個)。
E105 形式 データ行が1行以上あるか ファイル データ行がありません。
E106 形式 拡張子とファイルサイズが仕様の範囲か ファイル CSV ファイル(5MB まで)を選択してください。
E201 項目単体 必須項目が空でないか 項目 {項目名}は必須です。
E202 項目単体 桁数・文字数が上限以内か 項目 {項目名}は {n} 文字以内で入力してください。
E203 項目単体 数値の型と範囲 項目 数量は 1〜9999 の半角数字で入力してください。
E204 項目単体 日付の書式と、実在する日付か 項目 {項目名}は YYYY-MM-DD 形式で入力してください。
E205 項目単体 使える文字の種類 項目 備品コードは半角英数字とハイフンで入力してください。
E206 項目単体 改行を許可していない項目に改行が無いか 項目 {項目名}に改行は使えません。
E301 相関 日付の前後関係 行 廃棄日は購入日以降の日付にしてください。
E302 相関 ファイル内でキーが重複していないか 行 備品コードが {n} 行目と重複しています。
E303 相関 ある項目に値があるとき、別の項目が必須 行 廃棄日を入力するときは廃棄理由も入力してください。
E401 DB照合 マスタに存在するか 項目 分類コード {値} は登録されていません。
E402 DB照合 登録済みのキーと重複していないか 行 備品コード {値} は登録済みです。
E403 DB照合 更新対象が存在するか(更新・削除の取込のとき) 行 備品コード {値} が見つかりません。
E404 DB照合 取込む人に、その行を登録する権限があるか 行 この分類の備品を登録する権限がありません。

タブ区切りのテンプレートです。

コード 段階  検査内容    対象  エラー文言
E101    形式  文字コードがUTF-8として読めるか  ファイル    文字コードが UTF-8 ではありません。
E102    形式  ヘッダ行の項目名と並び順が仕様と一致するか   ファイル    ヘッダ行が仕様と一致しません。
E103    形式  データ行が上限以内か  ファイル    データ行が上限(1000行)を超えています。
E104    形式  行の項目数がヘッダと同じか   行   項目数が {n} 個です(正しくは 6 個)。
E105    形式  データ行が1行以上あるか    ファイル    データ行がありません。
E106    形式  拡張子とファイルサイズが仕様の範囲か  ファイル    CSV ファイル(5MB まで)を選択してください。
E201    項目単体    必須項目が空でないか  項目  {項目名}は必須です。
E202    項目単体    桁数・文字数が上限以内か    項目  {項目名}は {n} 文字以内で入力してください。
E203    項目単体    数値の型と範囲 項目  数量は 1〜9999 の半角数字で入力してください。
E204    項目単体    日付の書式と、実在する日付か  項目  {項目名}は YYYY-MM-DD 形式で入力してください。
E205    項目単体    使える文字の種類    項目  備品コードは半角英数字とハイフンで入力してください。
E206    項目単体    改行を許可していない項目に改行が無いか 項目  {項目名}に改行は使えません。
E301    相関  日付の前後関係 行   廃棄日は購入日以降の日付にしてください。
E302    相関  ファイル内でキーが重複していないか   行   備品コードが {n} 行目と重複しています。
E303    相関  ある項目に値があるとき、別の項目が必須 行   廃棄日を入力するときは廃棄理由も入力してください。
E401    DB照合    マスタに存在するか   項目  分類コード {値} は登録されていません。
E402    DB照合    登録済みのキーと重複していないか    行   備品コード {値} は登録済みです。
E403    DB照合    更新対象が存在するか(更新・削除の取込のとき) 行   備品コード {値} が見つかりません。
E404    DB照合    取込む人に、その行を登録する権限があるか    行   この分類の備品を登録する権限がありません。

エラーの返し方で決めること

決めること 選択肢 目安
行番号の数え方 ヘッダを1行目とする/データの1件目を1とする ヘッダを1行目とすると、表計算ソフトの行番号と合う。項目内に改行がある場合は「何件目のレコードか」で数えると決める
1回に返すエラーの数 最初の1件だけ/全部/上限つき(100件まで など) 全部(上限つき)。1件ずつ直して再取込を繰り返さずに済む
同じ行の複数のエラー 全部返す/最初の1つ 項目単体のエラーは全部返す
返す形 画面に一覧表示/エラー一覧の CSV をダウンロード/元の CSV にエラー列を足して返す 行数が多い取込は、エラー列を足した CSV が直しやすい
文言に入れる情報 行番号、項目名、入力された値、正しい書式 「どこを・どう直すか」が1文で分かるようにする

4つの段階を Python で動かす

上の一覧のうち11種類のエラーコードを実装した、そのまま動くサンプルです。データベースは標準ライブラリの sqlite3 をメモリ上で使っています。8行のデータのうち、正常な行は2行目と9行目の2つです。

import csv, io, sqlite3
from datetime import date

HEADER = ["備品コード", "備品名", "分類コード", "数量", "購入日", "廃棄日"]
MAX_ROWS = 1000

CSV_TEXT = """備品コード,備品名,分類コード,数量,購入日,廃棄日
EQ-101,プロジェクター,AV,2,2026-04-01,
EQ-102,,AV,1,2026-04-01,
EQ-103,モニター,AV,多数,2026/04/01,
EQ-104,ノートPC,PC,5,2026-04-10,2026-04-01
EQ-105,三脚,ZZ,1,2026-05-01,
EQ-001,スピーカー,AV,1,2026-05-01,
EQ-106,延長コード,PC,3
EQ-107,Webカメラ,PC,4,2026-06-01,
"""

def setup():
    db = sqlite3.connect(":memory:")
    db.executescript("""
      CREATE TABLE categories (code TEXT PRIMARY KEY);
      INSERT INTO categories VALUES ('AV'), ('PC');
      CREATE TABLE items (code TEXT PRIMARY KEY, name TEXT NOT NULL, category TEXT NOT NULL,
                          qty INTEGER NOT NULL, bought TEXT NOT NULL, disposed TEXT);
      INSERT INTO items VALUES ('EQ-001', 'ホワイトボード', 'AV', 1, '2025-01-10', NULL);
    """)
    return db

def to_date(s):
    try:
        return date.fromisoformat(s)
    except ValueError:
        return None

def check(db, raw):
    """(正常行のリスト, エラーのリスト) を返す。エラーは (行番号, コード, 文言)"""
    # 段階1: 形式チェック(ファイル全体)。ここでエラーなら以降へ進まない
    try:
        text = raw.decode("utf-8-sig")
    except UnicodeDecodeError:
        return [], [(0, "E101", "文字コードが UTF-8 ではありません。")]
    records = list(csv.reader(io.StringIO(text, newline="")))
    if not records or records[0] != HEADER:
        return [], [(1, "E102", "ヘッダ行が仕様と一致しません。")]
    if len(records) - 1 > MAX_ROWS:
        return [], [(0, "E103", f"データ行が上限({MAX_ROWS}行)を超えています。")]

    ok, errors, seen = [], [], {}
    for no, rec in enumerate(records[1:], start=2):
        errs = []
        if len(rec) != len(HEADER):
            errors.append((no, "E104", f"項目数が {len(rec)} 個です(正しくは {len(HEADER)} 個)。"))
            continue
        code, name, cat, qty, bought, disposed = (v.strip() for v in rec)
        # 段階2: 項目単体チェック
        if not name:
            errs.append((no, "E201", "備品名は必須です。"))
        if not qty.isascii() or not qty.isdigit() or not 1 <= int(qty) <= 9999:
            errs.append((no, "E203", "数量は 1〜9999 の半角数字で入力してください。"))
        d1 = to_date(bought)
        if d1 is None:
            errs.append((no, "E204", "購入日は YYYY-MM-DD 形式で入力してください。"))
        d2 = to_date(disposed) if disposed else None
        if disposed and d2 is None:
            errs.append((no, "E204", "廃棄日は YYYY-MM-DD 形式で入力してください。"))
        # 段階3: 相関チェック(項目単体が通った行だけ)
        if not errs:
            if d2 and d2 < d1:
                errs.append((no, "E301", "廃棄日は購入日以降の日付にしてください。"))
            if code in seen:
                errs.append((no, "E302", f"備品コードが {seen[code]} 行目と重複しています。"))
        seen.setdefault(code, no)
        # 段階4: DB照合チェック(ここまで通った行だけ)
        if not errs:
            if not db.execute("SELECT 1 FROM categories WHERE code = ?", (cat,)).fetchone():
                errs.append((no, "E401", f"分類コード {cat} は登録されていません。"))
            if db.execute("SELECT 1 FROM items WHERE code = ?", (code,)).fetchone():
                errs.append((no, "E402", f"備品コード {code} は登録済みです。"))
        if errs:
            errors.extend(errs)
        else:
            ok.append((code, name, cat, int(qty), bought, disposed or None))
    return ok, errors

def import_csv(raw, mode):
    db = setup()
    ok, errors = check(db, raw)
    with db:  # 1トランザクション。例外が出れば全体が取り消される
        if mode == "all_or_nothing" and errors:
            inserted = 0
        else:
            db.executemany("INSERT INTO items VALUES (?,?,?,?,?,?)", ok)
            inserted = len(ok)
    total = db.execute("SELECT COUNT(*) FROM items").fetchone()[0]
    return inserted, errors, total

raw = CSV_TEXT.encode("utf-8-sig")
for mode in ("all_or_nothing", "valid_rows_only"):
    inserted, errors, total = import_csv(raw, mode)
    print(f"[{mode}] 取込 {inserted} 行 / エラー {len(errors)} 件 / items の行数 {total}")
for no, code, msg in errors:
    print(f"  {no}行目 {code} {msg}")

print(check(setup(), "備品コード,備品名\r\n".encode("cp932"))[1])
print(check(setup(), "コード,名前\r\n".encode("utf-8"))[1])
[all_or_nothing] 取込 0 行 / エラー 7 件 / items の行数 1
[valid_rows_only] 取込 2 行 / エラー 7 件 / items の行数 3
  3行目 E201 備品名は必須です。
  4行目 E203 数量は 1〜9999 の半角数字で入力してください。
  4行目 E204 購入日は YYYY-MM-DD 形式で入力してください。
  5行目 E301 廃棄日は購入日以降の日付にしてください。
  6行目 E401 分類コード ZZ は登録されていません。
  7行目 E402 備品コード EQ-001 は登録済みです。
  8行目 E104 項目数が 4 個です(正しくは 6 個)。
[(0, 'E101', '文字コードが UTF-8 ではありません。')]
[(1, 'E102', 'ヘッダ行が仕様と一致しません。')]

4行目は項目単体のエラーが2つ同時に返り、5行目以降は段階ごとのエラーが1つずつ返っています。4行目の購入日(2026/04/01)は書式エラーになるので、相関チェックと DB 照合には進みません。

全件ロールバックと正常行だけ取込の違いと選び方

上の実行結果のとおり、同じファイルでも方式によってデータベースに入る行数が変わります(全件ロールバックは 0 行、正常行だけ取込は 2 行)。

比べる点 全件ロールバック(1行でもエラーなら全部取り消す) 正常行だけ取込
取込後の状態 「全部入った」か「何も入っていない」のどちらか 一部だけ入った状態がありうる
利用者の直し方 元のファイルを直して、同じファイルをもう一度取り込む エラーの行だけを抜き出して直し、その行だけ取り込む
再取込のしやすさ そのまま再取込できる 取込済みの行をもう一度送ると「登録済み」エラーになるため、エラー行だけのファイルを返す仕組みが要る
行どうしの関係 親子関係や合計の一致が保たれる 片方だけ入ると整合しなくなる場合がある
件数が多いとき 1件の誤りで全体をやり直す 正しい行は先に進められる

選ぶ判断基準

条件 選ぶ方式
行どうしに関係がある(見出し行と明細行、合計が一致する必要がある) 全件ロールバック
取込が別の処理(通知、集計、在庫の引き当て)のきっかけになる 全件ロールバック
行が互いに独立していて、数百行以下 全件ロールバック(直して同じファイルを再取込するほうが分かりやすい)
行が互いに独立していて、数千行以上。誤りが少数まじることが多い 正常行だけ取込+エラー行だけの CSV を返す
夜間などに自動で取り込み、人がすぐに直せない 正常行だけ取込+エラーを記録して通知
どちらとも決めにくい 取込前に検査だけを行う「確認」と「取込」の2段階にし、取込は全件ロールバックにする

どちらを選んでも、取り込んだ件数・エラーの件数・取り込まなかった件数を結果画面に表示すると仕様に書いておきます。

数式インジェクション対策

CSV を表計算ソフトで開くと、= などで始まる項目が数式として解釈されることがあります。利用者が入力した文字列を CSV に出力する機能では、仕様書に対策を書いておきます。OWASP の「CSV Injection」のページには、注意する先頭文字として =、+、-、@、タブ(0x09)、復帰(0x0D)、改行(0x0A)と、全角の = + - @ が挙げられています。

対策 内容 向いている場面 気をつける点
先頭に ' を付ける 対象の文字で始まる文字列の項目に、単一引用符を前置きする。項目は二重引用符で囲み、項目内の二重引用符は2つ重ねる 人が表計算ソフトで開く出力 出力した CSV を別のプログラムで読むと、' が値に含まれる
先頭にタブを付ける 対象の項目の先頭に、囲みの中でタブ文字を入れる(OWASP のページに、表計算ソフトで保存し直したあとも効く方法として挙げられている) 同上 後続のプログラム処理に影響しうる
入力時に制限する 対象の文字で始まる値を、入力チェックで受け付けない コードや番号など、記号で始まる必要が無い項目 自由記述の項目には向かない
用途でファイルを分ける 人が開く用(対策あり)と、システム連携用(対策なし)を別の出力にする 両方の用途がある 2種類の仕様表が必要になる

OWASP のページでも、すべての表計算ソフトと後続処理に通用する1つの方法は無いと説明されています。「誰が何で開くか」を仕様表に書いたうえで対策を選ぶのが要点です。

先頭に ' を付ける方法のサンプルです。数値の型の項目(マイナスの数量など)はそのまま出力し、文字列の項目だけを対象にしています。

import csv, io

RISKY = ("=", "+", "-", "@", "\t", "\r", "\n", "=", "+", "-", "@")

def neutralize(value):
    """文字列の項目が数式として解釈されないよう、先頭に ' を付ける"""
    if isinstance(value, str) and value.startswith(RISKY):
        return "'" + value
    return value

rows = [
    ["EQ-001", "=1+1", 3],
    ["EQ-002", "@SUM(A1:A2)", -5],
    ["EQ-003", "-在庫少", 10],
    ["EQ-004", "=HYPERLINK(...)", 0],
    ["EQ-005", "プロジェクター", 2],
]
buf = io.StringIO()
w = csv.writer(buf, quoting=csv.QUOTE_ALL, lineterminator="\r\n")
for r in rows:
    w.writerow([neutralize(v) for v in r])
print(buf.getvalue())
"EQ-001","'=1+1","3"
"EQ-002","'@SUM(A1:A2)","-5"
"EQ-003","'-在庫少","10"
"EQ-004","'=HYPERLINK(...)","0"
"EQ-005","プロジェクター","2"

3列目の -5 は数値として渡しているので、そのまま出力されています。文字列か数値かを項目ごとの仕様(型の列)で決めておくと、対策の対象がはっきりします。

CSV仕様とExcelファイル・固定長・JSONの違い

受け渡しの形式を選ぶ段階で迷いやすいものとの比較です。

形式 向いている場面 型の情報 人が編集しやすいか
CSV 表の形のデータを、人とシステムの両方で扱う 無い(すべて文字。仕様表で決める) 表計算ソフトで編集できる
Excel ファイル(xlsx) 人が入力する様式を配り、書式や入力規則も付けたい セルごとにある 編集しやすい。プログラムで読むにはライブラリが要る
タブ区切り(TSV) 値にカンマが多く含まれる 無い 表計算ソフトに貼り付けやすい
固定長 相手のシステムが固定長だけに対応している 無い(桁位置で決まる) 編集しにくい
JSON 入れ子の構造がある、システムどうしで受け渡す 文字列・数値・真偽値などの区別がある 表の形では編集しにくい
条件 選ぶ形式
利用者が表計算ソフトで作って取り込む。項目は表の形 CSV
入力の手引きや選択肢のリストも一緒に配りたい Excel ファイル
見出しと明細のような入れ子がある JSON、または見出しと明細を別々の CSV にする
システムどうしの連携で、人は開かない JSON か BOM なし UTF-8 の CSV

確かめて分かったこと

  • CRLF で出力したファイルでも、項目内の改行は LF のまま残りました。 csv モジュールの lineterminator はレコードの区切りにだけ使われます。「改行コードは CRLF」と書くときは、項目内の改行をどうするかも別の行で決めます。
  • BOM 付きのファイルを utf-8 で読むと、エラーにならずに先頭の項目名だけが変わりました。 文字数が5から6になり、DictReader では項目名が一致しなくなります。エラーが出ないぶん気づきにくいので、取込側は utf-8-sig で読むと決めておくと安心です。
  • utf-8-sig は BOM が無いファイルもそのまま読めました。 取込側をこの指定にしておけば、「BOM は有無のどちらも可」という仕様を1行で満たせます。
  • ファイルの行数とレコード数は一致しませんでした(4 と 3)。 項目内に改行があると、表計算ソフトの行番号、テキストエディタの行番号、レコードの番号がずれます。エラーの行番号が何を指すかを仕様で決めておきます。
  • RFC 4180 が決めているのは区切り・囲み・改行までで、文字コードの既定や BOM、日付・数値の書式は範囲の外でした。 仕様表の半分以上は、自分たちで決める項目です。
  • 数式インジェクション対策は、数値の項目に当てると値が変わります。 サンプルでは -5 を数値のまま出力し、文字列の -在庫少 だけに ' を付けました。型を項目ごとに決めておくことが対策の前提になります。

よくある質問

Q. CSV の BOM とは何ですか?付けるべきですか? A. BOM は、UTF-8 のファイルの先頭に付く3バイト(EF BB BF)の目印です。利用者が表計算ソフトで開く CSV には付け、プログラムどうしの受け渡しでは付けないのが基本で、取り込む側は有無のどちらも受け付けるようにします。

Q. CSV の文字コードは UTF-8 と Shift_JIS のどちらにすべきですか? A. 新しく決めるなら UTF-8 をおすすめします。Shift_JIS(CP932)は絵文字など変換できない文字があるため、受け取る側のシステムが Shift_JIS だけに対応している場合に選び、変換できない文字の扱いを仕様に書きます。

Q. CSV アップロードのエラーチェックはどの順番で行いますか? A. 形式、項目単体、相関、DB照合の順です。前の段階を通った行だけを次の段階で検査すると、意味のない検査や余分なデータベースへの問い合わせを省けます。

Q. CSV 取込でエラーがあったとき、全件取り消しと一部取込のどちらがよいですか? A. 行どうしに関係があるなら全件取り消し、行が独立していて件数が多いなら正常行だけ取込が向いています。迷うときは、検査だけを行う「確認」と「取込」を分け、取込は全件取り消しにします。

Q. CSV の項目にカンマや改行、ダブルクォートを含めるにはどうしますか? A. 項目全体を二重引用符で囲み、項目内の二重引用符は2つ重ねます。出力は csv モジュールなどのライブラリに任せ、文字列の連結で組み立てないようにすると、囲みの付け忘れを防げます。

まとめ

  • CSV の仕様は、CSVファイル仕様表(ファイル全体+項目ごと)とエラーチェック一覧の2つで決めます。
  • 文字コードは UTF-8。表計算ソフトで開くなら BOM 付き、取込側は BOM の有無と CRLF・LF の両方を受け付けます。
  • 項目内の改行と二重引用符は、囲みと "" で表します。項目内の改行を許可する項目は、仕様表で指定します。
  • 取込時の検査は形式 → 項目単体 → 相関 → DB照合の順。エラーコードの帯を段階ごとに分け、文言には行番号・項目名・正しい書式を入れます。
  • 全件ロールバックか正常行だけ取込かは、行どうしの関係と再取込のしやすさで選び、結果画面に件数を表示します。
  • 表計算ソフトで開く出力には、数式として解釈されうる文字列の項目への対策を仕様に入れます。

参考・出典

  • RFC 4180: Common Format and MIME Type for Comma-Separated Values (CSV) Files(https://www.rfc-editor.org/rfc/rfc4180 )
  • OWASP「CSV Injection」(https://community.owasp.org/attacks/CSV_Injection )

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「CSV出力・取込の仕様設計|文字コード・BOMとエラーチェック一覧」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?