SQLAlchemy 2.0 #4 セッション — 変更追跡、flush と commit、オブジェクトの 4 つの状態

Core では conn.execute() で SQL を直接送り込みました。ORM は働き方が違います。オブジェクトを作り、書き換え、削除すると、セッション(Session)がその変更を覚えておいて、適切なタイミングで SQL に変換して送り出します。 この「覚えておいて、まとめて」が単位作業(Unit of Work)パターンで、セッションを理解するとはこのタイミングを理解することにほかなりません。

セッションを作る — sessionmaker #

セッションはエンジンからコネクションを借りて使う、寿命の短い作業空間です。毎回 Session(engine) で作ることもできますが、設定を固定したファクトリをひとつ作っておくのが慣例です。

main.py
from sqlalchemy.orm import Session, sessionmaker

SessionLocal = sessionmaker(engine)

# 基本パターン: ブロック全体がひとつのトランザクション
with SessionLocal.begin() as session:
    session.add(User(name="佐藤", email="sato@example.com"))
# 正常終了 → commit、例外 → rollback

# 細かく制御したいとき
with SessionLocal() as session:
    user = User(name="鈴木", email="suzuki@example.com")
    session.add(user)
    session.commit()

sessionmaker.begin() は第 2 回で見た engine.begin() の ORM 版です。短いスクリプトならこの形がデフォルトで、コミットのタイミングを自分で決める必要があるときだけ 2 つ目の形を使います。

変更はすぐには SQL になりません — flush と commit #

セッションの核心の動きをコードで確認します。

main.py
with SessionLocal() as session:
    user = User(name="高橋", email="takahashi@example.com")
    session.add(user)      # まだ SQL なし。セッションが「新規オブジェクト」と記憶するだけ
    print(user.id)         # None。INSERT 前なので主キーがない

    session.flush()        # ここで INSERT 実行(トランザクションは開いたまま)
    print(user.id)         # 1。DB が発行した主キーが入る

    session.commit()       # トランザクション確定
  • flush: セッションにたまった変更(INSERT、UPDATE、DELETE)を SQL に変換して DB に送ります。トランザクションの中で実行されるだけで、確定ではありません。
  • commit: flush をもう一度実行してからトランザクションを確定します。だからほとんどのコードでは flush を直接呼ぶ必要がありません。

flush を自分で呼ぶ代表的なケースは、上のコードのように DB が発行する主キーがコミット前に必要なときです。またセッションはクエリを実行する直前に自動で flush します(autoflush)。さっき add したオブジェクトが直後の select にちゃんと出てくるのはこのおかげです。

更新と削除も同じ方式です。取得してきたオブジェクトの属性を書き換えると、それだけで UPDATE の対象になります。 save のような呼び出しはありません。

main.py
with SessionLocal.begin() as session:
    user = session.get(User, 1)   # 主キーで取得
    user.name = "高橋(改)"        # セッションが変更を検知(dirty)
    session.delete(session.get(User, 2))  # DELETE を予約
# ブロック終了時に UPDATE と DELETE がまとめて flush + commit

オブジェクトの 4 つの状態 #

セッションとオブジェクトの関係は 4 つの状態に整理できます。エラーメッセージにそのまま登場する用語なので、知っておくとデバッグが速くなります。

状態意味いつ
transientセッションと無関係な新規オブジェクトUser(...) の直後
pendingセッションに登録されたがまだ INSERT 前session.add() の後
persistentDB の行と結びついている(主キーあり)flush 後、または取得してきたオブジェクト
detachedDB の行と結びついていたがセッションが閉じたセッション終了後

実務で問題になるのは主に detached です。セッションが閉じたあとに、ロードされていない属性(特に次回扱うリレーション属性)へアクセスすると DetachedInstanceError になります。「セッションの外に出たオブジェクトは、もう DB と会話できない」という原則さえ覚えておけば、原因はすぐ見えます。

identity map — 同じ行は同じオブジェクトです #

ひとつのセッションの中で同じ主キーを 2 回取得すると、セッションは 2 回目の取得ですでに持っているそのオブジェクトをそのまま返します。

main.py
with SessionLocal() as session:
    a = session.get(User, 1)
    b = session.get(User, 1)
    print(a is b)  # True。同じ Python オブジェクト

これが identity map です。セッションが「主キー → オブジェクト」の辞書を維持しているので、ひとつのトランザクションの中で同じ行を指すオブジェクトが 2 つでき、互いに違う値を持つという事故が構造的に起きません。裏を返せば、セッションが違えば同じ行でも別オブジェクトです。セッション A で変更した内容がセッション B のオブジェクトに自動反映されることはありません。

commit 後の最初のアクセスがなぜクエリを発行するのか — expire #

デフォルト設定(expire_on_commit=True)では、コミットはセッション内の全オブジェクトの属性を期限切れにします。コミット後に初めて属性へアクセスすると、セッションが SELECT を再実行して最新値を取ってきます。コミットの合間に別のトランザクションが値を変えたかもしれないので、古い値を持ち続けないための安全装置です。

この動作は実務上のポイントを 2 つ生みます。

  • コミット後にループでオブジェクトの属性をひとつずつ読むと、SELECT が複数回飛ぶことがあります。echo=True で確認すればすぐ見えます。
  • コミット後にセッションを閉じてから属性にアクセスすると、期限切れの属性を再ロードするセッションがないので、先ほどの DetachedInstanceError になります。API レスポンスとしてオブジェクトを返す前に必要な値を先に読んでおくか、レスポンス用データへの変換(Pydantic モデルなど)をセッションの中で終わらせるのが定石です。

セッションのスコープ — リクエスト 1 つにセッション 1 つ #

セッションをどこで作りどこで閉じるかはアーキテクチャの問題です。原則はひとつです。作業単位 1 つにセッション 1 つ。 Web アプリケーションなら HTTP リクエスト 1 つが作業単位です。

  • グローバルなセッションを使い回すのは禁物です。セッションはスレッドセーフではなく、エラーでロールバックが必要な状態になると、以降のすべてのリクエストが汚染されます。
  • FastAPI なら依存性注入でリクエストごとにセッションを作って渡し、レスポンス後に閉じます。具体的なパターンはモダン Python 実践 #3 で扱いました。
  • バッチ処理なら論理的な処理単位(ファイル 1 つ、チャンク 1 つ)ごとにセッションを開閉します。数十万件をひとつのセッションで処理すると、変更追跡の対象がたまってメモリと flush 時間が一緒に伸びていきます。

まとめ #

  • セッションは変更をため込み、flush のタイミングで SQL として送り出す単位作業のマネージャーです。add や属性変更はすぐには SQL になりません。
  • flush は SQL の送信、commit は flush + 確定です。DB 発行の主キーが先に必要なときだけ flush を直接呼びます。
  • オブジェクトは transient、pending、persistent、detached の 4 状態を行き来します。セッションの外で未ロード属性に触れると DetachedInstanceError です。
  • ひとつのセッションの中では同じ行は常に同じオブジェクトです(identity map)。コミットは属性を期限切れにし、次のアクセスで再取得させます。
  • スコープの原則は作業単位ごとにセッション 1 つです。グローバルセッションは使いません。
  • 次回は relationship です。1:N、N:M の宣言と、ORM 最大の罠である N+1 問題を扱います。
X