PythonでCSVファイルを効率的に読み書きする方法:csv・pandas・polars比較

PythonでCSVを読み書きする基礎と実装。csv/pandas/polarsの実測ベンチマーク、不正なCSV・エンコーディング・dtype推論の落とし穴、大容量ファイル処理、性能最適化までPythonコードで解説します。

CSVファイルとは

CSV(Comma-Separated Values)は、カンマ区切りでデータを格納するテキスト形式のファイルです。データの受け渡しや分析の入出力として広く利用されています。Pythonには標準ライブラリのcsvモジュールのほか、pandaspolarsといったサードパーティライブラリがあり、用途や規模に応じて使い分けることが重要です。

1. 標準ライブラリ csvモジュール

1.1 csv.reader:基本的な読み込み

csv.readerはCSVファイルを行ごとにリストとして読み込みます。

import csv

with open("data.csv", "r", encoding="utf-8") as f:
    reader = csv.reader(f)
    header = next(reader)  # ヘッダー行を取得
    print(f"カラム: {header}")
    for row in reader:
        print(row)  # ['値1', '値2', '値3']

1.2 csv.writer:基本的な書き込み

import csv

data = [
    ["名前", "年齢", "都市"],
    ["田中", 30, "東京"],
    ["佐藤", 25, "大阪"],
    ["鈴木", 35, "名古屋"],
]

with open("output.csv", "w", encoding="utf-8", newline="") as f:
    writer = csv.writer(f)
    writer.writerows(data)

newline=""を指定しないと、Windows環境で空行が挿入される問題が発生します。

1.3 DictReader / DictWriter:辞書形式での操作

カラム名をキーとした辞書形式で操作できるため、コードの可読性が向上します。

import csv

# 辞書形式で読み込み
with open("data.csv", "r", encoding="utf-8") as f:
    reader = csv.DictReader(f)
    for row in reader:
        print(row["名前"], row["年齢"])  # カラム名でアクセス

# 辞書形式で書き込み
fieldnames = ["名前", "年齢", "都市"]
rows = [
    {"名前": "田中", "年齢": 30, "都市": "東京"},
    {"名前": "佐藤", "年齢": 25, "都市": "大阪"},
]

with open("output.csv", "w", encoding="utf-8", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=fieldnames)
    writer.writeheader()
    writer.writerows(rows)

1.4 区切り文字やクォートのカスタマイズ

TSV(タブ区切り)やセミコロン区切りにも対応できます。

import csv

# TSVファイルの読み込み
with open("data.tsv", "r", encoding="utf-8") as f:
    reader = csv.reader(f, delimiter="\t")
    for row in reader:
        print(row)

# クォート処理のカスタマイズ
with open("data.csv", "w", encoding="utf-8", newline="") as f:
    writer = csv.writer(f, quoting=csv.QUOTE_ALL)  # すべてのフィールドをクォート
    writer.writerow(["名前", "住所, 東京都", "備考"])

2. pandasによるCSV操作

2.1 read_csv:柔軟な読み込み

pandas.read_csvは非常に多くのオプションを備えており、実務で最もよく使われます。

import pandas as pd

# 基本的な読み込み
df = pd.read_csv("data.csv")

# よく使うオプションを指定した読み込み
df = pd.read_csv(
    "data.csv",
    encoding="utf-8",       # エンコーディング指定
    header=0,               # ヘッダー行のインデックス(Noneでヘッダーなし)
    index_col=0,            # インデックスに使用する列
    dtype={"年齢": int, "売上": float},  # 型を明示的に指定
    usecols=["名前", "年齢", "売上"],    # 必要な列のみ読み込み
    na_values=["N/A", "-", ""],          # 欠損値として扱う文字列
    parse_dates=["日付"],   # 日付型に変換する列
    nrows=1000,             # 先頭N行のみ読み込み
)

2.2 to_csv:書き出し

import pandas as pd

df = pd.DataFrame({
    "名前": ["田中", "佐藤", "鈴木"],
    "年齢": [30, 25, 35],
    "都市": ["東京", "大阪", "名古屋"],
})

# 基本的な書き出し
df.to_csv("output.csv", index=False, encoding="utf-8")

# よく使うオプション
df.to_csv(
    "output.csv",
    index=False,            # インデックス列を出力しない
    encoding="utf-8-sig",   # Excelで開く場合はBOM付きUTF-8
    columns=["名前", "都市"],  # 出力する列を指定
    sep="\t",               # タブ区切りで出力
    na_rep="N/A",           # 欠損値の表記
)

2.3 行のフィルタリングと集計

import pandas as pd

df = pd.read_csv("sales.csv")

# 条件によるフィルタリング
tokyo_sales = df[df["都市"] == "東京"]
high_sales = df[df["売上"] > 100000]

# 複合条件
result = df[(df["都市"] == "東京") & (df["年齢"] >= 30)]

# グループ別集計
summary = df.groupby("都市").agg(
    売上合計=("売上", "sum"),
    売上平均=("売上", "mean"),
    件数=("売上", "count"),
).reset_index()

print(summary)

2.4 複数CSVのマージ

import pandas as pd
import glob

# 同じ形式のCSVを結合
files = glob.glob("data/sales_*.csv")
dfs = [pd.read_csv(f) for f in files]
combined = pd.concat(dfs, ignore_index=True)

# 2つのCSVをキーで結合
customers = pd.read_csv("customers.csv")
orders = pd.read_csv("orders.csv")
merged = pd.merge(orders, customers, on="顧客ID", how="left")

3. polarsによるCSV操作

polarsはRust製の高速データフレームライブラリです。pandasと似たAPIを持ちながら、大規模データで大幅に高速に動作します。

import polars as pl

# 読み込み
df = pl.read_csv("data.csv")

# 型指定付き読み込み(polars 0.20.31以降は dtypes ではなく schema_overrides を使う)
df = pl.read_csv(
    "data.csv",
    schema_overrides={"年齢": pl.Int32, "売上": pl.Float64},
    encoding="utf8",
)

# フィルタリングと集計
result = (
    df.filter(pl.col("都市") == "東京")
    .group_by("カテゴリ")
    .agg(
        pl.col("売上").sum().alias("売上合計"),
        pl.col("売上").mean().alias("売上平均"),
        pl.col("売上").count().alias("件数"),
    )
)

# 書き出し
result.write_csv("output.csv")

dtypes引数は古いバージョンのpolarsの記法で、schema_overridesに改名されて久しく、dtypesのまま使うとDeprecationWarningが出ます(本記事はpolars 1.42.1で動作確認済み)。ネット上のサンプルコードにはdtypesのままのものが多いため、書籍やブログのコードをコピーする際は注意してください。

遅延評価(Lazy API)

polarsの大きな特徴であるLazy APIを使うと、クエリの最適化が自動的に行われます。

import polars as pl

# 遅延評価でクエリを構築
result = (
    pl.scan_csv("large_data.csv")  # LazyFrameとして読み込み
    .filter(pl.col("売上") > 10000)
    .group_by("都市")
    .agg(pl.col("売上").sum())
    .sort("売上", descending=True)
    .collect()  # ここで初めて実行される
)

scan_csvは必要なデータのみを読み込むため、大容量ファイルでもメモリ効率が良くなります。

4. 大容量CSVの効率的な処理

数GB以上のCSVファイルを処理する場合、メモリに全データを載せられないことがあります。

4.1 pandasのchunksize

import pandas as pd

# チャンク単位で読み込み・処理
chunk_size = 10000
results = []

for chunk in pd.read_csv("large_data.csv", chunksize=chunk_size):
    # 各チャンクを個別に処理
    filtered = chunk[chunk["売上"] > 10000]
    results.append(filtered)

# 結果を結合
final = pd.concat(results, ignore_index=True)

4.2 itertoolsとcsvモジュールによる省メモリ処理

メモリ使用量を最小限に抑えたい場合は、csvモジュールとジェネレータを組み合わせます。

import csv
from itertools import islice

def read_csv_chunks(filepath, chunk_size=10000):
    """CSVファイルをチャンク単位で読み込むジェネレータ"""
    with open(filepath, "r", encoding="utf-8") as f:
        reader = csv.reader(f)
        header = next(reader)
        while True:
            chunk = list(islice(reader, chunk_size))
            if not chunk:
                break
            yield header, chunk

# 使用例:売上の合計を省メモリで計算
total_sales = 0
for header, chunk in read_csv_chunks("large_data.csv"):
    sales_idx = header.index("売上")
    total_sales += sum(float(row[sales_idx]) for row in chunk)

print(f"売上合計: {total_sales:,.0f}")

4.3 polarsのストリーミング処理

import polars as pl

# ストリーミングモードで大容量ファイルを処理
result = (
    pl.scan_csv("very_large_data.csv")
    .filter(pl.col("ステータス") == "完了")
    .group_by("都市")
    .agg(pl.col("売上").sum())
    .collect(engine="streaming")  # ストリーミングエンジンで実行
)

collect(streaming=True)という書き方はpolars 1.25.0で非推奨になり、collect(engine="streaming")に置き換わりました(streaming=Trueのままでも動作しますがDeprecationWarningが出ます)。polarsチームは2025年にストリーミングエンジンをmorsel-driven parallelism(データを小さな塊=morselに分割し、Rustの非同期タスクで並列処理する方式)で全面的に書き直しており、順次デフォルトエンジンへ格上げされる計画です。大容量CSVを扱うなら、常に最新のエンジン指定方法を公式ドキュメントで確認することをおすすめします。

5. エンコーディング問題の対処法

日本語環境では、CSVファイルのエンコーディングに関する問題が頻繁に発生します。

5.1 よくあるエンコーディングと対処

エンコーディング用途Pythonでの指定
UTF-8標準的なエンコーディングencoding="utf-8"
UTF-8 BOMExcel出力encoding="utf-8-sig"
Shift_JISWindows日本語環境encoding="shift_jis"
CP932Shift_JISの拡張encoding="cp932"
EUC-JP旧Unix系システムencoding="euc_jp"

5.2 UnicodeDecodeErrorの実例とエンコーディング自動判定

Windows環境でExcelから「CSV(Windows)」として保存したファイルはShift_JIS(CP932)でエンコードされています。これをそのままutf-8で開こうとすると、次のように実際にエラーになります。

import pandas as pd

# Shift_JISで書かれたファイルをutf-8として読もうとする
df = pd.read_csv("sjis_sample.csv")
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x96 in position 0: invalid start byte

こうしたエラーに遭遇したら、まずファイルのバイト列からエンコーディングを推定します。chardetと、より新しいcharset-normalizerrequestsが内部で使用しており、活発にメンテナンスされている)の2つを実際に同じファイルへ適用して比較してみます。

import chardet
from charset_normalizer import from_path

with open("sjis_sample.csv", "rb") as f:
    raw = f.read()

# chardet
result = chardet.detect(raw)
print(result)
# {'encoding': 'cp932', 'confidence': 0.0387..., 'language': 'ja', 'mime_type': 'text/plain'}

# charset-normalizer
best = from_path("sjis_sample.csv").best()
print(best.encoding, best.language)
# cp932 Japanese

両者ともcp932という正しい結果にたどり着きますが、注目すべきはchardetconfidence3.87%しかないことです。数百バイト程度の小さなファイルでは、統計的な判定が難しくShift_JIS系(CP932/Shift_JIS/EUC-JP)は文字コード空間が近く判別しにくいため、正解していても確信度が低く出ることがあります。「confidenceが低いから判定を信用しない」という単純な閾値判定は、日本語CSVでは誤って正しい推定を捨てることになりかねません。ファイルサイズが数万行以上ある場合は判定はより安定しますが、確信度に依存しすぎず、可能であればデータの出所(Windows由来ならCP932、UNIX系ならUTF-8、といった業務知識)から先に当たりをつける方が実務では確実です。

import pandas as pd
from charset_normalizer import from_path

def read_csv_auto_detect(filepath):
    """charset-normalizerでエンコーディングを推定してCSVを読み込む"""
    best = from_path(filepath).best()
    if best is None:
        raise ValueError(f"エンコーディングを判定できませんでした: {filepath}")
    return pd.read_csv(filepath, encoding=best.encoding)

df = read_csv_auto_detect("sjis_sample.csv")

5.3 エンコーディングエラーへの対処

import pandas as pd

# エラーを無視して読み込み(データ欠損の可能性あり)
df = pd.read_csv("data.csv", encoding="utf-8", encoding_errors="ignore")

# エラー箇所を置換文字に変換
df = pd.read_csv("data.csv", encoding="utf-8", encoding_errors="replace")

# 複数のエンコーディングを順番に試す
def read_csv_auto(filepath):
    """複数のエンコーディングを試してCSVを読み込む"""
    encodings = ["utf-8", "utf-8-sig", "cp932", "shift_jis", "euc_jp"]
    for enc in encodings:
        try:
            return pd.read_csv(filepath, encoding=enc)
        except (UnicodeDecodeError, UnicodeError):
            continue
    raise ValueError(f"読み込みに失敗しました: {filepath}")

5.4 ExcelでCSVを開くためのBOM付きUTF-8出力

Microsoft ExcelでUTF-8のCSVを正しく表示するには、BOM(Byte Order Mark)を付ける必要があります。

import pandas as pd

df = pd.DataFrame({"名前": ["田中", "佐藤"], "売上": [100, 200]})

# BOM付きUTF-8で書き出し(Excelで文字化けしない)
df.to_csv("for_excel.csv", index=False, encoding="utf-8-sig")

6. 実践例:データの前処理パイプライン

複数の処理を組み合わせた実践的な例を示します。文字列のパターンマッチングには 正規表現 が便利です。

import pandas as pd
import re

def process_sales_data(input_path, output_path):
    """売上データの前処理パイプライン"""
    # 1. 読み込み(エンコーディング自動対応)
    df = pd.read_csv(input_path, encoding="cp932", parse_dates=["日付"])

    # 2. 不要な列の削除
    df = df.drop(columns=["備考", "更新日時"], errors="ignore")

    # 3. 欠損値の処理
    df["売上"] = df["売上"].fillna(0)
    df["都市"] = df["都市"].fillna("不明")

    # 4. データ型の変換
    df["売上"] = df["売上"].astype(int)

    # 5. 正規表現による電話番号のクレンジング
    df["電話番号"] = df["電話番号"].apply(
        lambda x: re.sub(r"[^\d]", "", str(x)) if pd.notna(x) else ""
    )

    # 6. フィルタリング(売上が0より大きい行のみ)
    df = df[df["売上"] > 0]

    # 7. 集計列の追加
    df["月"] = df["日付"].dt.to_period("M")

    # 8. 書き出し
    df.to_csv(output_path, index=False, encoding="utf-8-sig")
    print(f"処理完了: {len(df)}行を出力しました")

process_sales_data("raw_sales.csv", "cleaned_sales.csv")

繰り返し実行する処理には デコレータ でログ出力やリトライ機能を追加すると便利です。

7. csv・pandas・polars比較

項目csv(標準)pandaspolars
インストール不要(標準)pip install pandaspip install polars
速度遅い中速高速
メモリ効率良い(行単位)普通良い(列指向)
機能の豊富さ最小限非常に豊富豊富
型安全性なし弱い強い
並列処理なしなし自動並列化
遅延評価なしなしあり(scan_csv)
日本語対応手動良好良好
学習コスト低い中程度中程度
推奨用途小規模・単純処理分析・変換全般大規模データ処理

両ライブラリとも近年大きな変化がありました。pandasは2023年リリースの2.0系でApache Arrowベースの新しいバックエンド(dtype_backend="pyarrow")を導入し、2026年1月リリースの3.0系では文字列専用のstrdtype(PyArrow裏付け、未インストール時はNumPy object dtypeにフォールバック)がデフォルトで有効になりました。文字列操作や文字列列のメモリ効率が旧来のobject dtypeより大きく改善されています。polarsは2024年に大規模言語モデル同様「1.0」のメジャーリリースを迎えて本番運用に耐える安定版と位置付けられ、2025年にはストリーミングエンジンの全面刷新(morsel-driven parallelism、詳細は後述)が進みました。バージョンによってAPIの挙動が変わる部分(dtypesschema_overridescollect(streaming=True)collect(engine="streaming")など)があるため、本記事のコードは実行時のバージョン(pandas 3.0.3 / polars 1.42.1)で動作確認しています。

性能比較ベンチマーク

「典型的にはpolarsが速い」と言うだけでは検証になりません。ここでは実際にベンチマークを実行し、計測した実測値をそのまま掲載します。

計測環境: Apple M1(8コア)/ macOS 26.5.2 / Python 3.14.6 / pandas 3.0.3 / polars 1.42.1 / pyarrow 25.0.0。CSVはID・氏名・年齢・都市・売上・日付・ステータス・スコア・数量・地域の10列(日本語の文字列列を含む)で、numpy.random.default_rng(42)で固定シード生成した100,000行・500,000行・1,000,000行の3種類。各パターンでread/writeを3回ずつ実行し、/usr/bin/time -lでプロセスのpeak memory footprintを計測、実行時間は各スクリプト内部でtime.perf_counter()により計測した中央値を採用しています(測定方法の詳細は本記事末尾のコードを参照)。

import time
import csv

filepath = "benchmark_data.csv"  # 実測は100,000 / 500,000 / 1,000,000行の3パターン

# --- csv.reader ---
start = time.perf_counter()
with open(filepath, "r", encoding="utf-8", newline="") as f:
    reader = csv.reader(f)
    header = next(reader)
    rows = list(reader)
csv_time = time.perf_counter() - start
print(f"csv.reader: {csv_time:.4f}秒 ({len(rows)}行)")
import time
import pandas as pd

start = time.perf_counter()
df_pd = pd.read_csv(filepath)
pandas_time = time.perf_counter() - start
print(f"pandas: {pandas_time:.4f}秒 ({len(df_pd)}行)")
import time
import polars as pl

start = time.perf_counter()
df_pl = pl.read_csv(filepath)
polars_time = time.perf_counter() - start
print(f"polars: {polars_time:.4f}秒 ({len(df_pl)}行)")

上記コードを100,000/500,000/1,000,000行のファイルそれぞれに対して実行した結果が次の表です(3回計測の中央値、read/writeとも秒単位)。

行数ファイルサイズcsv readpandas readpolars readcsv writepandas writepolars write
100,0006.7MB0.142秒0.363秒0.087秒0.100秒0.210秒0.010秒
500,00034MB0.608秒0.636秒0.106秒0.434秒1.042秒0.054秒
1,000,00068MB1.329秒1.028秒0.115秒0.868秒1.964秒0.077秒

csv・pandas・polarsのCSV読み込み/書き込み時間比較(100K/500K/1M行、対数軸、値が小さいほど高速)

読み込みでは1,000,000行においてpolarsがcsv比で約11.6倍、pandas比で約8.9倍高速でした。一方で興味深いのは書き込みです。pandasのto_csvはcsv標準ライブラリのcsv.writerより遅くなっています(1,000,000行で1.96秒 vs 0.87秒)。これは測定方法上の理由があります。このベンチマークでは、csvモジュール側はcsv.readerで読んだ文字列のままのリストをcsv.writerで書き戻しているため型変換が発生しませんが、pandas/polarsは一度read_csvで数値・日付列を型付きデータとして読み込んだ上で、to_csv/write_csvの際にそれらを再度テキストへ整形しています。この整形コストの分だけpandasの書き込みは遅くなります。polarsはRust側で列単位の書き込みを並列化しているため、この整形コストを負ってもcsvモジュールより高速です。つまり「pandasは書き込みが遅い」のではなく、「型付きデータを持っている場合、その整形コストは避けられず、polarsはその整形を並列化・vectorize化できるがpandasはそこまで最適化されていない」というのが正確な理解です。

csv・pandas・polarsのCSV読み込み時ピークメモリ使用量比較(100K/500K/1M行、プロセス全体のpeak memory footprint)

メモリ使用量では、1,000,000行の読み込みでcsvモジュールが796MB、pandasが387MB、polarsが279MBでした。csvモジュールが最も重いのは意外に思えるかもしれませんが、これはlist(reader)で全行をPythonのlist[list[str]]として保持しているためです。Pythonの文字列オブジェクトやリストには相応のオブジェクトヘッダのオーバーヘッドがあり、同じデータをNumPy配列やApache Arrow配列として持つより数倍のメモリを消費します。pandasはNumPy配列(一部pandas 3.0からはArrow文字列型)で列を保持するため小さくなり、polarsはApache Arrowのカラムナー形式かつメモリレイアウトがより密なため最小になります。

なぜpandas/polarsはcsvモジュールより速いのか

読み込み速度の差は、突き詰めると「どこで型変換とループを回しているか」の違いに帰着します。

  • csvモジュールcsv.readerが1行ずつPythonオブジェクト(文字列のリスト)を生成し、その後の型変換(int()float()呼び出しなど)もPythonインタプリタのループで行います。CPythonのforループは1回のイテレーションごとにバイトコード解釈のオーバーヘッドを伴うため、行数に比例してこのオーバーヘッドが積み重なります。
  • pandasread_csvはデフォルトでCで書かれたパーサ(Cパーサ)を使い、ファイルをブロック単位で読みながら列ごとに数値変換をNumPyのCレベルのループで行います。Pythonのforループを介さないため、csvモジュールより大幅に高速です。
  • polarsはRustで実装されたマルチスレッドパーサを持ち、ファイルを複数チャンクに分割して列ごと・チャンクごとに並列処理します。さらにApache Arrow形式のメモリレイアウト(列指向・連続領域・ゼロコピーに近いバッファ共有)を採用しているため、キャッシュ効率が高く、SIMD命令によるベクトル化も効きやすくなっています。pandasのCパーサがシングルスレッドであるのに対し、polarsはCPUコア数に応じて自動的にスレッドを使い切ろうとするため、コア数が多い環境ほど差が開きます(本ベンチマークのM1は8コア)。

scan_csv.collect()によるLazy APIがさらに速くなる場合があるのは、フィルタや列選択などのクエリ全体を最適化してから実行するためです。例えばusecols相当の列選択やフィルタ条件をパース段階まで押し下げる(predicate/projection pushdown)ことで、不要な列やパースを丸ごとスキップできます。pl.read_csvのような即時実行APIではこの最適化が働かないため、大規模データではLazy APIの方が有利になる場面があります。

8. 実務で遭遇する落とし穴

ここまでの比較は「正しい形式のCSVを高速に処理する」話でしたが、実務のCSVは往々にして構造が崩れていたり、型が期待通りに揃っていなかったりします。代表的な2つの落とし穴を、実際に動かして確認します。

8.1 クォート内の区切り文字・改行の扱い

CSVの仕様(RFC 4180)では、フィールドをダブルクォートで囲めばその中にカンマや改行を含められます。まずは仕様通りにクォートされたケースを確認します。

import csv
import io

csv_text = (
    '名前,住所,備考\n'
    '田中,"東京都渋谷区1-2-3\n(ビル4F)",VIP顧客\n'
    '佐藤,"大阪府, 大阪市北区",通常\n'
)

reader = csv.reader(io.StringIO(csv_text))
for row in reader:
    print(row)
['名前', '住所', '備考']
['田中', '東京都渋谷区1-2-3\n(ビル4F)', 'VIP顧客']
['佐藤', '大阪府, 大阪市北区', '通常']

csvモジュール・pandas・polarsのいずれも、クォートで囲まれたカンマ・改行はRFC 4180に従って正しく1つのフィールドとして扱います。「csvモジュールは改行入りフィールドを誤解釈する」という俗説を聞くことがありますが、少なくとも標準的なダイアレクトでは3ライブラリとも問題なく処理できることを確認済みです。

問題になるのは、クォートを付け忘れたままカンマを含むデータです。手作業でExcelから貼り付けたデータや、クォート処理を省略した独自出力スクリプトで発生しがちです。

import csv
import io
import pandas as pd
import polars as pl

# 2行目の住所にカンマが含まれるが、クォートされていない
csv_text = (
    "名前,住所,備考\n"
    "田中,東京都渋谷区1-2-3,VIP顧客\n"
    "佐藤,大阪府, 大阪市北区,通常\n"
)

print("--- csv.reader ---")
reader = csv.reader(io.StringIO(csv_text))
header = next(reader)
for row in reader:
    print(row, "len=", len(row))
--- csv.reader ---
['田中', '東京都渋谷区1-2-3', 'VIP顧客'] len= 3
['佐藤', '大阪府', ' 大阪市北区', '通常'] len= 4

csv.readerエラーを出さずに列数の異なる行(ragged row)をそのまま返します。2行目は4フィールドになっており、後続の処理でインデックス参照をしていると気づかないままズレたデータを使ってしまう危険があります。同じデータをpandasとpolarsで読むと、明示的にエラーで止まります。

print("--- pandas.read_csv ---")
try:
    df = pd.read_csv(io.StringIO(csv_text))
except Exception as e:
    print(f"{type(e).__name__}: {e}")

print("--- polars.read_csv ---")
try:
    df = pl.read_csv(csv_text.encode())
except Exception as e:
    print(f"{type(e).__name__}: {e}")
--- pandas.read_csv ---
ParserError: Error tokenizing data. C error: Expected 3 fields in line 3, saw 4


--- polars.read_csv ---
ComputeError: found more fields than defined in 'Schema'

Consider setting 'truncate_ragged_lines=True'.

pandasはParserError、polarsはComputeError明示的に失敗します。「csvモジュールは寛容だから安全」ではなく、むしろ黙って壊れたデータを返すぶん危険であり、pandas/polarsの「うるさく失敗する」挙動の方が事故を防げる、という点は覚えておく価値があります。

polarsのエラーメッセージにあるtruncate_ragged_lines=Trueを安易に使うと、今度は静かにデータが壊れます。

df = pl.read_csv(csv_text.encode(), truncate_ragged_lines=True)
print(df)
shape: (2, 3)
┌──────┬───────────────────┬─────────────┐
│ 名前 ┆ 住所              ┆ 備考        │
│ ---  ┆ ---               ┆ ---         │
│ str  ┆ str               ┆ str         │
╞══════╪═══════════════════╪═════════════╡
│ 田中 ┆ 東京都渋谷区1-2-3 ┆ VIP顧客     │
│ 佐藤 ┆ 大阪府            ┆  大阪市北区 │
└──────┴───────────────────┴─────────────┘

2行目の「備考」列に本来入るべき「通常」が消え、代わりに住所の後半( 大阪市北区)が入り込んでいます。truncate_ragged_linesは超過したフィールドを黙って捨てるだけで、正しい列位置に補正してくれるわけではありません。実務では、まずpd.read_csv(..., on_bad_lines="warn")のようにどの行が壊れているかを可視化してから、元データの出力ロジック側を直すのが安全です。

df = pd.read_csv(io.StringIO(csv_text), on_bad_lines="warn")
ParserWarning: Skipping line 3: expected 3 fields, saw 4

   名前           住所     備考
0  田中  東京都渋谷区1-2-3  VIP顧客

8.2 dtype推論の落とし穴:数値列に紛れ込んだ非数値

「見た目は数値の列」に1件だけ非数値の値が混入すると、pandasは列全体の型推論を諦めます。これは単なる型の問題ではなく、集計が例外を出さずに壊れた結果を返すという点で危険です。

import pandas as pd
import io

csv_text = "名前,売上\n田中,15000\n佐藤,23000\n鈴木,未確定\n高橋,18500\n"
df = pd.read_csv(io.StringIO(csv_text))
print(df.dtypes)
print(df)
print("売上の合計:", df["売上"].sum())

pandas 3.0.3で実行すると次のようになります。

名前    str
売上    str
dtype: object

   名前     売上
0  田中  15000
1  佐藤  23000
2  鈴木    未確定
3  高橋  18500

売上の合計: 1500023000未確定18500

「未確定」という1文字列のせいで、1500023000も含めて列全体が文字列型になり、.sum()は例外を出さずに文字列連結を行います。56500という正しい合計値ではなく1500023000未確定18500という壊れた文字列が返るにもかかわらず、プログラムはエラーで止まりません。これはTypeErrorで明示的に落ちるより厄介なサイレント障害です。

なお、pandas 3.0では文字列専用のstrdtype(PyArrow裏付け)がデフォルトで有効になっており、pandas 2.x系では同じ状況でobjectdtypeとして表示されます(挙動自体は同じで、型推論に失敗した列が丸ごと文字列になり.sum()が黙って文字列連結する点は2.x・3.0系のいずれでも再現します)。手元のpandas 2.3.3で検証したところ、同じデータでの列サイズはobjectdtypeで366バイト、pandas 3.0のstrdtypeでは188バイトとおよそ半分でした。文字列列のメモリ効率はpandas 3.0で改善されていますが、型推論失敗時の「静かに壊れる」挙動そのものは変わっていません。

対策は、型を明示指定するか、pd.to_numericで強制変換して不正値を可視化することです。

df["売上_fixed"] = pd.to_numeric(df["売上"], errors="coerce")
print(df)
print("欠損件数:", df["売上_fixed"].isna().sum())
print("正しい合計:", df["売上_fixed"].sum())
   名前     売上  売上_fixed
0  田中  15000   15000.0
1  佐藤  23000   23000.0
2  鈴木    未確定       NaN
3  高橋  18500   18500.0

欠損件数: 1
正しい合計: 56500.0

errors="coerce"は変換できない値をNaNにするため、isna().sum()で不正な値が何件混入していたかをすぐに把握できます。大容量ファイルでは、読み込み直後に主要な数値列へこのチェックをかけておくと、集計結果が黙って壊れる事故を防げます。polarsでも同様に、pl.read_csvが数値列をstr型にフォールバックした場合はpl.col(...).cast(pl.Float64, strict=False)で明示的に変換し、null_count()で欠損数を確認する運用が安全です。

9. パフォーマンスTips

大規模CSVを扱う際のパフォーマンス改善テクニックをまとめます。

9.1 必要な列だけ読み込む

import pandas as pd

# 全列読み込み(遅い)
df = pd.read_csv("large.csv")

# 必要な列だけ(速い・省メモリ)
df = pd.read_csv("large.csv", usecols=["名前", "売上"])

9.2 型を明示して読み込む

pandasは型推論に時間がかかるため、大容量ファイルでは型を明示すると高速化できます。

import pandas as pd

dtypes = {
    "ID": "int32",         # int64 → int32でメモリ半減
    "名前": "string",      # objectよりstringが効率的
    "売上": "float32",     # float64 → float32でメモリ半減
    "カテゴリ": "category", # カテゴリ型で大幅にメモリ削減
}

df = pd.read_csv("large.csv", dtype=dtypes)

9.3 Parquetフォーマットへの変換

繰り返し読み込むCSVは、Parquet形式に変換しておくと大幅に高速化されます。

import pandas as pd

# CSVをParquetに変換(初回のみ)
df = pd.read_csv("large.csv")
df.to_parquet("large.parquet", engine="pyarrow")

# 以降はParquetから読み込み(数倍〜数十倍高速)
df = pd.read_parquet("large.parquet")

9.4 大量のCSVを非同期で読み込む

大量のCSVファイルを同時に読み込む場合、 非同期処理 やマルチプロセスを活用できます。

from concurrent.futures import ProcessPoolExecutor
import pandas as pd
import glob

def process_file(filepath):
    """個別のCSVファイルを処理する関数"""
    df = pd.read_csv(filepath)
    return df[df["売上"] > 10000]

# マルチプロセスで並列処理
files = glob.glob("data/sales_*.csv")
with ProcessPoolExecutor(max_workers=4) as executor:
    results = list(executor.map(process_file, files))

combined = pd.concat(results, ignore_index=True)

まとめ

CSVファイルの処理は用途に応じてツールを選択することが重要です。

  • 小規模・単純な処理csvモジュール(依存なし。ただし列数不一致などの不正な行を黙って通すため、想定外の壊れたデータに弱い)
  • データ分析・変換pandas(最も広く使われている。実測では1,000,000行の読み込みが約1.03秒。型推論の失敗が.sum()などの集計をサイレントに壊すことがあるため、pd.to_numeric(errors="coerce")での確認を習慣にする)
  • 大規模データ・高速処理polars(自動並列化・遅延評価。実測では同じ1,000,000行の読み込みが約0.12秒とpandasの8〜9倍、csvモジュールの11倍以上高速)

日本語環境ではエンコーディングの問題が避けられないため、charset-normalizer(またはchardet)による自動判定とutf-8-sigの使い分けを習慣づけるとよいでしょう。ただし数百バイト程度の小さなファイルでは判定の確信度が低く出ることがあるため、確信度だけに頼らずデータの出所(Windows由来かUNIX系か)も併せて判断してください。また、繰り返し読み込むファイルはParquet形式に変換しておくことで、読み込み速度を大幅に改善できます。

CSVは仕様上シンプルに見えても、クォート漏れ・列数不一致・型推論の失敗といった落とし穴は実務で頻繁に発生します。ライブラリが「エラーで止まる」場合はまだ安全で、本当に危険なのは「エラーを出さずに壊れたデータを返す」ケースです。大容量データを扱うパイプラインほど、読み込み直後にレコード数・型・欠損数を検証するステップを組み込んでおくことをおすすめします。

関連記事

参考


関連ツール