Pythonのデータ分割をスマートに。itertools.batchedを使いこなそう

Pythonでのデータ処理において、大量のリストを100件ずつAPIで飛ばしたり、データベースへ一括で放り込んだりと、実務でデータを一定数ごとに区切るバッチ処理が必要になる場面はよくあります。
これまでは range でインデックスを計算して、スライスで data[i:i+n] と切り出すのが定番の書き方でした。ただ、この方法はシンプルに見えて、コードの中に計算式が混じるので少し読みづらかったり、うっかり境界値を間違えてバグを作ってしまったりと、意外と気を遣うポイントでもあります。
Python 3.12以降で標準になった itertools.batched を使うと、こういった処理が驚くほどスッキリ書けるようになります。
標準ライブラリの itertools に追加された batched は、リストなどのイテラブルなデータをN個ずつの塊にまとめてくれる関数です。
わざわざ慣れた書き方を変えるだけのメリットはどこにあるのか。スライス手法と比較しながら、実務での使い分けや、実際に使ってみて感じたメリットについて紹介します。
スライスとbatchedのコードの比較
リスト data を3つずつの塊に分ける処理を例に、これまでの定番だった書き方と、新しい書き方を並べてみます。
【これまでの書き方】スライスとrange
インデックス(添字)を計算しながら、リストを一定間隔で切り取っていく方法です。
data = ["A", "B", "C", "D", "E", "F", "G"]
# 0からリスト末尾まで、3つ飛ばしでループ
for i in range(0, len(data), 3):
batch = data[i : i + 3]
print(batch)
この書き方では、range(0, len(data), 3) で「どこからどこまでを、いくつ飛ばしで進むか」を指示し、さらにループの中で i : i + 3 というスライスの範囲を指定する必要があります。
シンプルに見えますが、パッと見たときに「3つずつ分ける」という本来の目的よりも、インデックスの計算式のほうが目立ってしまいます。また、i + 3 の部分で数値を間違えないように気を配ったり、リストの末尾を超えたときの挙動を意識したりと、単純な処理の割に読み手の脳を意外と使ってしまうのが難点でした。
【これからの書き方】itertools.batched
Python 3.12で追加された機能です。これまでの range を使った方法とは違い、分割したい数を指定するだけでデータをまとめてくれます。
from itertools import batched
data = ["A", "B", "C", "D", "E", "F", "G"]
# 「dataを3つずつの塊にする」と直感的に記述
for batch in batched(data, 3):
print(batch)
最大の違いは、インデックス( i )の管理が一切不要になった点です。
batched(data, 3) と書くだけで、「データを3つずつの塊にして取り出す」という意図がストレートに伝わります。
計算式が消えたことでコードがスッキリし、初めてこのコードを見る人でも、何のために回っているループなのかが一瞬で理解できるようになります。まさに、やりたいことをそのままコードに落とし込めるようになった感じです。
batchedを選ぶメリット
コードがすっきりする以外にも、itertools.batched に切り替える理由はいくつかあります。プログラムの安定性や効率に関わる部分など、実際に運用する上でスライス方式よりも安心できるポイントを紹介します。
特に、扱うデータが大量だったり、リスト以外の形式だったりする場合に、これまでの書き方ではカバーしきれなかった弱点を補うことができます。
- 対応できるデータ型の広さ(汎用性)
スライス( data[i:i+3] )は、リストや文字列のようにあらかじめ長さが決まっていて、添字でアクセスできるデータにしか使えません。
一方で batched は、データの総数が分からないジェネレータや、ファイルから1行ずつ読み込むストリームなど、あらゆる繰り返し可能なオブジェクトにそのまま使えます。データソースがリストから別の形式に変わっても、同じ書き方のまま動かせる柔軟性があります。 - メモリ効率(パフォーマンス)
スライスを実行すると、元のリストから指定した範囲をコピーして新しいリストをその都度作成します。このコピー処理が重なると、巨大なデータを扱う場合にメモリを食いつぶし、動作が重くなる原因になります。
batched は、必要な分だけを順番に取り出すイテレータとして動きます。全体を一度にコピーせず、最小限のメモリで大量のデータを流せるため、リソースの限られた環境でも動作が安定します。 - 境界値の安全性
ループの終了条件やインデックスの増分を自分で計算するスライス方式は、うっかり計算を間違えて「最後の1件が処理されなかった」あるいは「無限ループになった」といった、単純な実装ミスが起こりやすい場所でもあります。
batched なら、こうした細かい計算はすべて任せられます。端数の処理も自動で完結するので、人間がインデックスの計算に頭を悩ませる必要がなくなり、結果的にバグを未然に防ぐことにつながります。
このように、batched を使うことは、「どんなデータが来ても安定して、効率よく動くコード」を無理なく書くことにも繋がります。
今は小さなリストを扱っていても、将来的にデータ量が増えたり、読み込み元がファイルに変わったりする可能性を考えると、最初から batched を使っておくのが無難な選択です。
実務での活用シーン
itertools.batched が特に真価を発揮するのは、外部システムとの連携やリソースの最適化が必要な場面です。
よくある2つのケースを例に見てみましょう。
APIへの一括リクエスト
外部サービスなどのAPIを利用する際、「一度に送れるデータは100件まで」といった制限(レートリミットや仕様上の制約)があるのはよくあることです。こうした制限を守りつつ、大量のデータを効率よく捌きたい場合に最適です。
# 1,000件のデータを100件ずつのグループに分けて送信
for chunk in batched(user_ids, 100):
send_to_api(chunk)
これまでは「今何件目か」を自分でカウントしたり、スライスを計算したりしていましたが、batched ならループを回すだけで仕様通りのリクエストが送れます。
データベースへのバルクインサート
データベースの操作では、1件ずつ INSERT を発行するとネットワークの往復(オーバーヘッド)が増え、処理全体が非常に遅くなってしまいます。そこで、1,000件程度を一つの塊としてまとめて保存する「バルクインサート」が推奨されます。
# ストリームデータを1,000件ごとにまとめてDBへ保存
for batch in batched(raw_data_stream, 1000):
cursor.executemany("INSERT INTO table VALUES (?, ?)", batch)
raw_data_stream のような、全体の件数が事前にわからないイテレータ形式のデータでも、batched を挟むだけで簡単にバルク処理を組み込めます。
このように、batched を使えば、負荷の調整や高速化といった実務で外せない処理を、コードをスッキリ保ったまま書けます。
新しい書き方なのはもちろん、現場で求められる壊れにくくて速い処理をさらっと書けるようになり、面倒なインデックス計算を意識せずにやりたい処理だけを記述できるのが、この関数の大きな利点です。
リスト管理におけるスライスとbatchedの使い分け
元データがすでに「リスト」である場合、どちらを使うべきか迷うかもしれません。判断の決め手は「分割した後に何をしたいか」と「型(リストかタプルか)の制約」にあります。
パターンA:その場で処理して終わるなら batched
分割したデータをループ内でAPIに送ったり、画面に表示したりして、その後に保持する必要がない場合。
推奨: batched
リストの複製(コピー)が発生せず、メモリ消費を抑えて高速に処理できるため。元がリストの場合でも、処理効率の面でメリットがあります。
# データを送るだけなら、メモリ負荷の低い batched が最適
for batch in batched(data, 100):
send_to_api(batch)
パターンB:分割後のデータを書き換えたいなら スライス
3個ずつの塊にした後、その中身を編集(要素の置換など)したい場合。
推奨: スライス
batched は中身を書き換えられないタプルを返すため。切り出した後に要素を編集(置換など)するなら、最初からリストで返るスライスが効率的です。
# 切り出した後に中身をいじるなら、リストで返るスライスが楽
for i in range(0, len(data), 3):
batch = data[i : i + 3]
batch[0] = "Updated" # リストなので直接書き換え可能
パターンC:将来のデータ増を見越すなら batched
現在は小さなリストでも、将来的にソースが巨大なCSVやDBのストリームに変わる可能性がある場合。
推奨: batched
データソースが将来的にリストからジェネレータ(イテレータ)へ変わっても、ループ側のロジックを変更せずに済むため(拡張性)。
# ソースがリストでもイテレータでも、batched なら同じ書き方で動く
for batch in batched(data_or_stream, 100):
process(batch)
パターンD:分割計算は楽したいが、リストとして扱いたいなら batched + list()
スライスの計算(i:i+n)を書くのは面倒だが、中身を書き換えたり、リストを受け付ける既存の関数に渡したいという場合。
推奨: batched + list()
分割の計算を batched に任せつつ、ループ内で list(batch) と変換することで、管理の楽さとリストの柔軟性を両立できるため。
# 面倒な計算は batched に任せ、必要になったらリストに変換する
for batch in batched(data, 3):
batch_list = list(batch)
batch_list.append("new_item")
判断基準まとめ表
※横にスクロールして確認できます
| 状況・目的 | 推奨手法 | 戻り値の型 | 理由 |
|---|---|---|---|
| ループ内で送信・集計するだけ | batched | タプル | 速度・メモリ効率が最強。 |
| 分割後に中身を編集・加工したい | スライス | リスト | リストなら後から書き換えが可能なため。 |
| データが巨大、またはソースが不明 | batched | タプル | メモリ不足のリスクを回避できる。 |
| 計算を楽にしつつ、リストで使いたい | batched + list() | リスト | 計算は自動、操作はリストの柔軟さを維持。 |
| コードの読みやすさを最優先したい | batched | タプル | 複雑なスライス計算を排除できる。 |
基本的には、計算の手間がなくてメモリも食わない batched をメインに使い、どうしても中身をいじる必要があるときだけスライスや list() を選ぶ、という使い分けが一番スムーズです。
タプルで返ってくるという仕様も、中身をうかつに書き換えられない安全策だと捉えれば、実務ではむしろ使い勝手の良さに繋がります。
この型の違いを踏まえて、その時々の書きやすさで選ぶのがいいと思います。
まとめ:これからの標準的なデータ分割
手元のリストをサクッと切り出すならスライスは便利ですが、繰り返しの中でデータを分けるなら、itertools.batched を使うのがスムーズです。
計算式を書かなくていいし、巨大なデータでもメモリの負荷を抑えられる。リスト以外にも広く使い回せる点も含め、あえて自前でインデックス計算を管理する場面はそう多くありません。標準機能で書ける部分は任せてしまったほうが、結果的にコードもスッキリするでしょう。