最近、取引先から届いたCSVを社内ツールに取り込む処理を見直しました。
見た目は同じCSVでも、あるファイルはUTF-8、別のファイルはShift_JISです。区切り文字もカンマとは限らず、タブ区切りで送られてくることがあります。
最初は「読み込み時にUTF-8で開いて、だめならShift_JISにする」という処理でも動いていました。ただ、実際にやってみると、文字コードの判定だけでは列ずれや途中行の崩れまでは防げません。
CSVの取り込みで困ったときは、ファイルを単に「CSV」と考えるのではなく、次の要素に分けて確認すると整理しやすくなります。
- 文字コード
- 改行コード
- 区切り文字
- 引用符の扱い
- ヘッダーや列数など、業務上の期待値
この記事では、PythonでCSVを読み込む前に、これらを自動判定する考え方をまとめます。完全に機械任せにするのではなく、判定できない場合は安全に処理を止める構成が前提です。
まず文字コードを確認する
日本語のCSVで最初に問題になりやすいのは、UTF-8とShift_JISの違いです。
最近のWebサービスやクラウドシステムではUTF-8が多い一方で、Windows向けの業務ソフトや古いExcel運用では、Shift_JIS系のファイルが残っていることがあります。
厳密には、Windowsで使われる「Shift_JIS」と、Pythonで扱うshift_jisが完全に同じとは限りません。機種依存文字や拡張文字が含まれる場合があるため、実務ではcp932を使うことがあります。
例えば、次のような文字があると、shift_jisではデコードできず、cp932なら扱えるケースがあります。
髙橋
①
㈱
UTF-8には、ファイルの先頭にBOMが付く場合もあります。BOM付きUTF-8を通常のutf-8で読むと、先頭の列名に見えない文字が混ざり、"\ufeff顧客名"のような値になることがあります。
そのため、BOMがある場合はutf-8-sigとして読むと、BOMを取り除いた状態で扱えます。
基本的な判定処理は、次のようにできます。
import codecs
def detect_encoding(raw: bytes) -> str:
if raw.startswith(codecs.BOM_UTF8):
return "utf-8-sig"
for encoding in ("utf-8", "cp932"):
try:
raw.decode(encoding)
return encoding
except UnicodeDecodeError:
pass
raise ValueError("対応していない文字コードです")
ここで重要なのは、デコードできたからといって、必ず正しい文字コードとは限らないことです。
例えば、ファイルの内容が英数字だけなら、UTF-8でもCP932でも問題なくデコードできます。この場合、バイト列だけから正解を決めることはできません。
だからこそ、取引先ごとの設定や、列名・住所などの期待値と組み合わせる必要があります。文字コード判定は、まず候補を絞る処理と考えた方が安全だと思います。
判定の順番を決める
私がCSV取り込みで意識している順番は、次の通りです。
- ファイルをバイト列として読む
- BOMと文字コードを確認する
- 改行コードを確認する
- いくつかの候補から区切り文字を判定する
- CSVとして数行読み、列数の安定性を確認する
- 業務上の条件を満たさなければ処理を止める
いきなりopen(..., encoding="utf-8")で読み始めると、どの段階で問題が起きたのか分かりにくくなります。
最初にバイト列を読むことで、BOMや改行コードを確認できます。その後、選んだ文字コードでテキストに変換し、CSVパーサーに渡します。
改行コードは、バイト列からおおよその傾向を確認できます。
def detect_newline(raw: bytes) -> str | None:
crlf = raw.count(b"\r\n")
lf = raw.count(b"\n") - crlf
cr = raw.count(b"\r") - crlf
counts = {
"\r\n": crlf,
"\n": lf,
"\r": cr,
}
newline, count = max(counts.items(), key=lambda item: item[1])
return newline if count > 0 else None
ただし、改行コードの判定結果を、そのまま文字列の分割に使う必要はありません。
Pythonのcsv.readerを使う場合は、ファイルを開くときにnewline=""を指定するのが大切です。引用符の中に改行が含まれるCSVでも、CSVパーサーがレコード単位で処理しやすくなります。
区切り文字は候補を比較する
CSVという名前でも、実際には次のような区切り文字が使われています。
- カンマ
, - タブ
\t - セミコロン
; - パイプ
|
csv.Sniffer().sniff()を使う方法もあります。短いコードで推測できるため、試作では便利です。
一方で、データの内容によっては誤判定します。住所にカンマが含まれている、1行目だけ列数が違う、サンプルが短すぎる、といった場合です。
そのため、候補ごとにCSVとして読み込み、各行の列数が安定しているか確認する方法があります。
import csv
import io
from itertools import islice
def detect_delimiter(text: str) -> str:
candidates = [",", "\t", ";", "|"]
results = []
for delimiter in candidates:
reader = csv.reader(
io.StringIO(text[:100_000], newline=""),
delimiter=delimiter,
)
rows = list(islice(reader, 50))
widths = [
len(row)
for row in rows
if any(cell.strip() for cell in row)
]
if len(widths) < 2 or max(widths) < 2:
continue
common_width = max(set(widths), key=widths.count)
stability = sum(
width == common_width for width in widths
) / len(widths)
results.append((stability, delimiter, common_width))
if not results:
raise ValueError("区切り文字を判定できません")
results.sort(reverse=True)
best = results[0]
if len(results) > 1 and best[0] - results[1][0] < 0.1:
raise ValueError("区切り文字の候補が複数あります")
return best[1]
この例では、各候補で読み込んだときに列数がそろうほど高く評価しています。
例えば、カンマ区切りのファイルをタブ区切りとして読むと、ほとんどの行が「1列」として扱われます。逆に、タブ区切りのファイルをカンマで読むと、同じように列数が増えません。
ただ、列数が安定しているだけでは十分ではありません。データ内に区切り文字が一度も出てこないファイルでは、どの候補でも1列に見えることがあります。
最低限、次の条件も確認するとよいと思います。
- 2列以上ある
- ヘッダーの列数がデータ行と一致する
- 期待する列名が存在する
- 数行だけでなく、ファイル全体でも列数が大きく崩れていない
改行コードと引用符を混同しない
改行コードには、Windowsでよく使われるCRLF、LinuxやmacOSでよく使われるLFがあります。
CSVの1レコードが1物理行とは限らない点にも注意が必要です。例えば、備考欄に改行を含むデータは、次のようになります。
顧客名,備考
山田商事,"初回連絡済み
担当者確認待ち"
このファイルを単純にtext.split("\n")で分割すると、1件のデータを2件として扱ってしまいます。
改行コードを確認すること自体は有効ですが、レコード分割はcsv.readerに任せる方が安全です。
with open(
"input.csv",
"r",
encoding=encoding,
newline="",
) as file:
reader = csv.reader(file, delimiter=delimiter)
for row in reader:
print(row)
引用符は通常ダブルクォートです。ただし、取引先のシステムによっては、すべての項目を引用する場合と、カンマや改行を含む項目だけ引用する場合があります。
引用符の中に区切り文字がある場合、その文字を列の区切りとして数えてはいけません。文字列を検索してカンマの数を数えるだけの実装は、ここで崩れることがあります。
判定できない場合は処理を止める
自動判定で一番大切なのは、必ず何かを返すことではありません。
候補が2つ以上あり、どちらが正しいか判断できない場合に、推測したまま取り込むと、後続の請求処理や集計結果まで壊れることがあります。
例えば、次のような条件では処理を停止する方が安全です。
- 文字コード候補を複数に絞れない
- 区切り文字のスコアが近い
- ヘッダーの列数とデータ行の列数が一致しない
- 必須列が見つからない
- 行によって列数が大きく変わる
- ファイルが空、またはヘッダーしかない
- 文字化けの疑いがある文字列が含まれる
ここでいう停止は、担当者が原因を確認できる形で止めることが前提です。
「読み込みに失敗しました」だけでは、再び同じ作業を繰り返すことになります。判定結果や候補を表示し、必要なら手動で文字コードや区切り文字を指定できるようにすると、復旧しやすくなります。
取り込みログに残す項目
CSVの自動判定は、あとから確認できて初めて運用に組み込みやすくなります。
私は少なくとも、次の項目をログに残すようにしています。
- ファイル名
- ファイルサイズ
- 受信日時
- 判定した文字コード
- BOMの有無
- 改行コード
- 区切り文字
- ヘッダーの列数
- 読み込んだ行数
- 列数エラーの行番号
- 処理結果と停止理由
例えば、次のようなログがあると、担当者との確認がかなり楽になります。
file=customer_202607.csv
encoding=cp932
newline=CRLF
delimiter=TAB
columns=8
rows=1240
status=stopped
reason=column_count_mismatch line=318
このログから、「文字コードは問題なさそうだが、318行目だけ列数が違う」と切り分けできます。
一方で、顧客情報を含むCSVの内容そのものをログに出すのは避けた方が安全です。必要なら行番号、列数、ハッシュ値など、内容を直接残さない情報で調査できるようにします。
失敗しやすい実装
よくある失敗は、拡張子だけで形式を判断することです。
.csvだからカンマ区切り、という保証はありません。拡張子はファイル名の情報であり、実際のバイト列や構造を表していないことがあります。
次に、文字コードの例外だけで判定する方法です。UTF-8としてデコードできなかったらCP932、という実装は入り口としては使えます。
ただ、ASCIIだけのファイルや、誤っても例外にならない組み合わせでは判定できません。ヘッダーや列数の検証を追加する必要があります。
また、最初の1行だけを見て区切り文字を決めるのも危険です。ヘッダーには区切り文字が多く、データ行には少ないことがあります。逆のパターンもあるため、複数行を使う方が安定します。
最後に、判定処理と業務処理を一つの関数に詰め込まないことです。
「判定する処理」「検証する処理」「取り込む処理」「エラーを表示する処理」を分けておくと、取引先ごとに例外ルールが増えたときも修正範囲を抑えられます。
まとめ
CSVの取り込みで起きる文字化けや列ずれは、読み込み処理だけの問題に見えて、実際にはファイル形式の前提がそろっていないことから起きています。
まずバイト列から文字コードと改行コードを確認し、その後に複数の候補から区切り文字を判定します。最後に、ヘッダー、列数、必須列などの業務ルールで検証します。
自動判定には限界があります。だからこそ、曖昧なときに無理に読み込まず、判定結果と停止理由を残す設計が現実的だと感じています。
同じようなCSV・Excel変換を社内業務に合わせて小さくツール化したい場合は、名古屋業務ツール工房で無料相談できます。入力データと出力形式を整理するところから一緒に確認できます。