2026年7月11日

2026年7月11日

Pythonのgeneratorとyieldの使い方|大きなログをメモリを抑えて処理する

Pythonのジェネレーター(generator)は、yield を使って値を1つずつ作り出す関数です。リストのように全件を先にメモリへ載せず、必要になった分だけ計算するため、数GBのアクセスログや大量のレコードでもメモリ使用量を抑えて処理できます。この記事では、yield の動き方、ジェネレーター式、yield from、処理をつなぐパイプラインの書き方、そして「2回目のループが空になる」などのつまずきを、運用スクリプトの例で解説します。

コード例は(2026年10月現在)サポートが続いている Python 3 系を前提にしています。

returnとyieldの違い

通常の関数は return で値を1つ返して終わります。yield を含む関数は、呼び出してもすぐには本体を実行せず、「ジェネレーターオブジェクト」を返します。本体は next() や for 文で値を要求されたときに初めて動き、yield に到達するとそこで一時停止して値を渡します。次に要求されると、停止した位置の続きから再開します。

def countdown(n):
    print("開始")
    while n > 0:
        yield n
        n -= 1
    print("終了")

gen = countdown(3)   # ここではまだ「開始」は表示されない
print(next(gen))     # 開始 → 3
print(next(gen))     # 2
for x in gen:        # 残りの 1 を出力し、「終了」を表示してループを抜ける
    print(x)

関数の最後まで進むと StopIteration 例外が発生し、for 文はそれを合図にループを終えます。next() を手で呼ぶ場合は、next(gen, None) のように既定値を渡すと例外の代わりに既定値が返ります。

リストとのメモリ比較

全件をリストで作る場合と比べると、違いがはっきりします。

import sys

squares_list = [x * x for x in range(1_000_000)]
squares_gen = (x * x for x in range(1_000_000))

print(sys.getsizeof(squares_list))  # 数MB規模(要素への参照を全件保持)
print(sys.getsizeof(squares_gen))   # 数百バイト程度(件数に関係なくほぼ一定)

sys.getsizeof は容器そのものの大きさしか測らないため厳密な比較ではありませんが、ジェネレーターは件数が増えても使用量がほとんど変わらない、という傾向は確認できます。数値は Python のバージョンや環境で変わります。

ジェネレーター式

リスト内包表記の角括弧を丸括弧に変えると、ジェネレーター式になります。関数の引数として1つだけ渡す場合は、外側の括弧を省略できます。

# 5xx エラーの行数を数える(ファイル全体をメモリに載せない)
with open("/var/log/nginx/access.log", encoding="utf-8", errors="replace") as f:
    errors = sum(1 for line in f if '" 5' in line)
print(errors)

ファイルオブジェクト自体も1行ずつ読み出すイテレーターなので、f.readlines() や f.read() で全体を読み込まずに済みます。errors="replace" は、ログに不正なバイト列が混じっていても途中で止まらないようにするための指定です。

パイプラインとして処理をつなぐ

ジェネレーターの本領は、小さな処理をつないでパイプラインを作れることです。各段階は「前段から1件受け取り、加工して1件渡す」だけなので、全体として1行ずつ流れていきます。

import re
from collections import Counter

LOG_RE = re.compile(r'^(\S+) \S+ \S+ \[[^\]]+\] "(\S+) (\S+) [^"]*" (\d{3})')

def read_lines(path):
    with open(path, encoding="utf-8", errors="replace") as f:
        for line in f:
            yield line.rstrip("\n")

def parse(lines):
    for line in lines:
        m = LOG_RE.match(line)
        if m:
            ip, method, path, status = m.groups()
            yield {"ip": ip, "method": method, "path": path, "status": int(status)}

def only_errors(records):
    for r in records:
        if r["status"] >= 500:
            yield r

records = only_errors(parse(read_lines("/var/log/nginx/access.log")))
top = Counter(r["path"] for r in records).most_common(10)
for path, count in top:
    print(count, path)

段階ごとに関数が分かれているので、parse だけを単体でテストしたり、only_errors を別の条件に差し替えたりが簡単です。正規表現は一般的な combined 形式を想定した例なので、自分のサーバーのログ形式に合わせて調整してください。

先頭だけ取り出す・まとめて区切る

ジェネレーターは添字で取り出せませんが、itertools を使うと部分的に取り出せます。

from itertools import islice

first_5 = list(islice(read_lines("/var/log/syslog"), 5))

一定件数ずつまとめてDBに書き込みたい場合は、Python 3.12以降なら itertools.batched が使えます。それより前のバージョンでは islice で同じことを書けます。

from itertools import islice

def chunks(iterable, size):
    it = iter(iterable)
    while batch := list(islice(it, size)):
        yield batch

for batch in chunks(parse(read_lines("access.log")), 1000):
    print(len(batch))  # ここで 1000 件ずつまとめて INSERT するなど

yield fromで別のジェネレーターに任せる

yield from は、別のイテラブルが出す値をそのまま順に流します。複数のログファイルを1本の流れとして扱う例です。

import glob

def read_many(pattern):
    for path in sorted(glob.glob(pattern)):
        yield from read_lines(path)

for line in read_many("/var/log/nginx/access.log*"):
    pass

ローテーション済みのファイルが gzip 圧縮されている場合は、gzip.open(path, "rt", ...) で開く分岐が別途必要です。

ジェネレーターの中で return 値 を書くと、その値は StopIteration に入って呼び出し側へ伝わり、result = yield from sub() の形で受け取れます。for 文では受け取れないため、集計結果を返したい場合は使いどころを選びます。

send()とclose()

ジェネレーターには外から値を送り込む send() もあります。yield 式の評価結果が送り込まれた値になります。最初の1回は next()(または send(None))で yield まで進めておく必要があります。

def running_average():
    total = 0.0
    count = 0
    average = None
    while True:
        value = yield average
        total += value
        count += 1
        average = total / count

avg = running_average()
next(avg)            # 最初の yield まで進める
print(avg.send(10))  # 10.0
print(avg.send(20))  # 15.0
avg.close()

close() を呼ぶと、停止中の yield の位置で GeneratorExit が発生し、finally やコンテキストマネージャーの後片付けが実行されます。send() を使う設計は読み手にとって分かりにくくなりがちなので、非同期処理が目的であれば asyncio を使う方が素直です。

よくあるつまずき

2回目のループで何も出てこない

ジェネレーターは一度使い切ると空になり、巻き戻せません。同じデータを2回走査したい場合は、ジェネレーター関数をもう一度呼び出して新しいオブジェクトを作るか、件数が小さいと分かっているなら list() で保存します。デバッグのために print(list(gen)) を挟むと、その時点で中身を消費してしまう点にも注意が必要です。

len()や添字が使えない

ジェネレーターは長さを持たないため len(gen) は TypeError になります。件数だけが欲しいなら sum(1 for _ in gen) で数えられますが、これも中身を消費します。

エラーが起きるタイミングが遅れる

ジェネレーター関数は呼び出した時点では本体を実行しません。引数のチェックやファイルを開く処理を本体に書くと、エラーは最初に値を取り出した時点まで発生しません。早めに失敗させたい処理は、ジェネレーターを返す通常の関数側で実行してから内側のジェネレーターを返す、という分け方をします。

ジェネレーターの中でStopIterationを投げる

Python 3.7以降、ジェネレーター本体の中で発生した StopIteration は RuntimeError に置き換えられます(PEP 479)。途中で終えたいときは raise StopIteration ではなく return を使います。

途中でやめるとファイルがすぐ閉じない

read_lines のように内部で with open() しているジェネレーターを途中で使うのをやめると、ファイルが閉じられるのはジェネレーターが close() されるか、ガベージコレクションで回収されたときです。長時間動くプロセスで大量に作る場合は、contextlib.closing(gen) で囲むか、使い終わったら gen.close() を呼んでください。

まとめ

  • yield を含む関数はジェネレーターを返し、値を要求されたときに1件ずつ計算する
  • 大きなログや大量レコードは、ファイルを1行ずつ読むジェネレーターでつなぐとメモリを抑えられる
  • 処理を小さなジェネレーターに分けてパイプラインにすると、テストや差し替えがしやすい
  • islice・batched(3.12以降)・yield from で部分取得やまとめ処理を書ける
  • 一度きりで巻き戻せないこと、エラーの発生が遅れること、途中終了時の後片付けに注意する

お気軽にご相談ください

お見積りへ お問い合わせへ
▲