この記事の対象読者
- Pythonのデータ分析ライブラリPolarsを使用している方
- インメモリでCSVデータを扱おうとしている(例:ファイルに保存せずAPIレスポンスとして返したい)方
-
StringIOとBytesIOの使い分けや、文字コードに起因する問題に関心のある方
はじめに
Polarsは非常に高速なデータフレームライブラリですが、特定のバージョンでwrite_csvメソッドをio.StringIOと組み合わせて使うと、マルチバイト文字(日本語など)が正しく出力されないという問題がありました。
本記事では、Polars v1.30.0で発生したこの具体的な事象と、io.BytesIOを使った回避策、そしてその技術的な背景について解説します。
現在はPolars v1.32.0でこのバグは修正されていますが、ライブラリの内部動作やI/O処理の理解を深める良いケーススタディとなります。
Polars v1.30.0で発生した問題
PolarsのDataFrameをCSV形式の「文字列」としてメモリ上で取得したい場合、io.StringIOを使うのが一般的な方法の一つです。
Polars v1.29.0までは、以下のコードで期待通りに動作していました。
import io
import polars as pl
df = pl.DataFrame([{"name": "あいうえおかき", "price": 100}])
buf = io.StringIO()
df.write_csv(buf)
print("Polars version", pl.__version__)
print(buf.getvalue())
出力(v1.29.0)
Polars version 1.29.0
name,price
あいうえおかき,100
ところが、v1.30.0にアップデートすると、同じコードで意図しない出力が得られました。
# (コードは上記と同一)
出力(v1.30.0)
Polars version 1.30.0
name,price
あいうえおかき,100
おかき,100
このように、後半のデータが重複し、一部が切り出されたような余分な行が追加されてしまいました。
技術的背景:StringIO vs BytesIO
この問題はなぜ発生したのでしょうか?鍵となるのはStringIOとBytesIOの性質の違いと、Polars内部でのデータ処理方法です。
-
io.StringIO: 文字列をメモリ上で扱うためのバッファで、文字単位でカーソルを操作 -
io.BytesIO: バイナリをメモリ上で扱うためのバッファで、バイト単位でカーソルを操作
CSVファイルは本質的にバイトのストリームです。特にUTF-8のような可変長エンコーディングでは、1文字が1バイト以上で表現されます(例:「あ」はUTF-8で3バイト)。
v1.30.0のwrite_csvは、内部的にバイト単位での書き込みやシーク(カーソル移動)を行っていたと推測されます。しかし、書き込み先がStringIOだったため、文字数とバイト数の不一致からカーソル位置の計算にズレが生じ、結果としてデータの破損や重複が発生したと考えられます。
この推測は、io.BytesIOを使うと問題が解決したことからも裏付けられます。
回避策と恒久対応
回避策: io.BytesIOの使用
問題が発生していたv1.30.0では、書き込み先をStringIOからBytesIOに変更することで、期待通りの結果を得ることができました。BytesIOはバイト単位でデータを扱うため、ライブラリ内部のバイト単位の処理と整合性が取れたのです。
import io
import polars as pl
df = pl.DataFrame([{"name": "あいうえおかき", "price": 100}])
buf = io.BytesIO() # 修正
df.write_csv(buf)
print("Polars version", pl.__version__)
print(buf.getvalue().decode()) # 修正
出力(v1.30.0)
Polars version 1.30.0
name,price
あいうえおかき,100
この経験から、write_csvのようにバイナリデータを扱う操作をインメモリで行う場合は、BytesIOを使う方がより安全といえそうです。
恒久対応: Polarsのバグ修正
この問題はPolars側のバグであったことが判明し、後のバージョンで修正されました。
Polars v1.32.0以降では、StringIOを使っても当初のコードが問題なく動作します。ライブラリ側でStringIOへの書き込みが適切に処理されるようになったためです。
まとめ
Polars v1.30.0では、write_csvとStringIOを組み合わせるとマルチバイト文字が破損するバグが存在しました。
原因は、ライブラリ内部のバイト単位の処理と、StringIOのテキスト単位の処理との間の不整合と推測されます。
v1.30.0でもBytesIOを用いることでこの問題を回避できました。このことからファイルI/Oをメモリ上で行う際は、BytesIOがより堅牢な選択肢となる可能性がありそうです。
この問題はv1.32.0で修正済みであり、現在はStringIOでも安全に利用できます。
ライブラリのバージョンアップによって思わぬ挙動に遭遇することもありますが、その原因を探ることで、PythonのI/O処理や文字コードに関する理解を深める良い機会となりました。
以上