SQLAlchemy 2.0の新API—select()と型ヒント対応で変わった書き方

SQLAlchemy 2.0の新API—select()と型ヒント対応で変わった書き方 | mohablog
# SQLAlchemy 1.4までの書き方(Legacy Query API)
user = session.query(User).filter(User.id == 1).first()

# SQLAlchemy 2.0の推奨スタイル
user = session.execute(
    select(User).where(User.id == 1)
).scalar_one_or_none()

同じ処理なのにコードの雰囲気が別物になっています。SQLAlchemy 2.0.0は2023-01-26にリリースされ、ORMクエリの書き方からモデル定義のスタイルまで広範囲に刷新されました。既存プロジェクトで1.4から上げようとしたら、Session.query()の書き方がLegacy扱いになっていて驚いた、というのはよくある話です。

この記事では公式のWhat's New in SQLAlchemy 2.0?SQLAlchemy 2.0 - Major Migration Guideを読み直して、移行時に押さえるべき変更点を整理します。検証環境はPython 3.12 + SQLAlchemy 2.0.36 + PostgreSQL 16です。

目次

1.4と2.0の違いを並べて見る—変更点を一覧で把握する

まずはサジェストで「sqlalchemy 2.0 変更点」がよく検索されているので、主要な変更だけを表にまとめます。

項目1.4(Legacy)2.0(推奨)
クエリ実行session.query(User).filter(...).first()session.execute(select(User).where(...)).scalar_one_or_none()
モデル定義Column(Integer, primary_key=True)mapped_column(primary_key=True)
型ヒント型スタブ(sqlalchemy-stubs)が必要PEP 484に直接対応。Mapped[int]が公式サポート
Session scopesessionmaker()のみsessionmaker() + 非同期用async_sessionmaker()
非同期実験的AsyncSessionが正式サポート
Dataclass統合なしMappedAsDataclassが導入

Session.query()は消えるわけではない

ここで混乱しがちなのが「session.query()は廃止されるの?」という疑問です。公式のLegacy Query APIセクションを読むと、2.0系でも廃止予定はないと明記されています。内部的には新APIの上に実装されているため、古いコードはそのまま動きます。ただし新規コードではselect()ベースに統一するのが推奨です。

つまり大規模な既存プロジェクトを一気に書き換える必要はなく、段階的に移行していけます。

バージョンを確認する

自分の環境でどのバージョンが入っているか確認しておくと、移行戦略を立てやすくなります。

python -c "import sqlalchemy; print(sqlalchemy.__version__)"
2.0.36

2.0.0未満の場合、まず1.4の最終版(1.4.54)に上げてから2.0に移行するのが公式推奨です。1.4は2.0との互換ブリッジが入っているので、警告を潰しながら進められます。

select()中心の新しいクエリAPI

新APIの核はselect()関数です。ORMとCoreで同じ書き方に統一されたため、混乱が減りました。

基本的なSELECT

from sqlalchemy import select

# 全件取得
stmt = select(User)
result = session.execute(stmt)
for user in result.scalars():
    print(user.name)

# 単一行取得(存在しない場合はNone)
user = session.execute(
    select(User).where(User.email == "a@example.com")
).scalar_one_or_none()
-- 発行されるSQL
SELECT users.id, users.name, users.email FROM users;
SELECT users.id, users.name, users.email FROM users WHERE users.email = 'a@example.com';

ポイントはsession.execute()の戻り値がResultオブジェクトだということ。これに対して.scalars()(エンティティのリスト)、.scalar_one()(1件必須)、.scalar_one_or_none()(0 or 1件)を使い分けます。

where句とjoinの書き方

サジェストの「sqlalchemy 2.0 join」もよく調べられている領域です。

from sqlalchemy import select

# JOIN + フィルタ
stmt = (
    select(User, Post)
    .join(Post, User.id == Post.user_id)
    .where(Post.published.is_(True))
    .order_by(User.created_at.desc())
    .limit(10)
)

for user, post in session.execute(stmt):
    print(user.name, post.title)
-- 実行されるSQL
SELECT users.*, posts.*
FROM users JOIN posts ON users.id = posts.user_id
WHERE posts.published IS true
ORDER BY users.created_at DESC
LIMIT 10;

メソッドチェーンで積み上げていく「generative approach」と公式ドキュメントが呼んでいる書き方で、各メソッドが新しいSelectオブジェクトを返す設計です。途中で条件を足し引きしやすく、動的なクエリ組み立てに向いています。

INSERTとUPDATEも同じ統一感

from sqlalchemy import insert, update, delete

# バルクINSERT(RETURNINGで挿入後の行を取得)
stmt = insert(User).values([
    {"name": "tanaka", "email": "t@example.com"},
    {"name": "sato", "email": "s@example.com"},
]).returning(User)
result = session.execute(stmt)
for user in result.scalars():
    print(user.id, user.name)

# UPDATE
session.execute(
    update(User).where(User.id == 1).values(name="tanaka_new")
)
session.commit()
INSERT INTO users (name, email) VALUES ('tanaka', 't@example.com'), ('sato', 's@example.com') RETURNING users.id, users.name, users.email;
UPDATE users SET name='tanaka_new' WHERE users.id = 1;

SELECT/INSERT/UPDATE/DELETE がすべて同じsession.execute(stmt)スタイルで統一されたので、覚えることが減りました。

PEP 484対応—MappedとMapped_columnで型ヒントが効く

2.0の目玉機能がこれです。公式がdeep integration with PEP 484 typing practicesと表現しているとおり、型ヒントが一級市民になりました。

古い書き方(1.4まで)

from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import declarative_base

Base = declarative_base()

class User(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True)
    name = Column(String(100), nullable=False)
    email = Column(String(255))

この書き方の問題は、user.nameの型がOptional[str]なのかstrなのかIDEが判断できないことでした。sqlalchemy-stubsという別のパッケージを入れて補完していた人も多いはずです。

新しい書き方(2.0推奨)

from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import String

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(100))
    email: Mapped[str | None]  # Nullable はOptionalで表現
# mypy でも pyright でも型が効く
user: User = session.execute(select(User)).scalar_one()
reveal_type(user.name)   # Revealed type is "builtins.str"
reveal_type(user.email)  # Revealed type is "Union[builtins.str, None]"

Mapped[int]のようにジェネリクスで型を書くと、NULL許容かどうかが型レベルで明確になります。型ヒント全般の活用についてはPythonの型ヒントを使いこなす!mypyでコード品質を大幅向上させる方法も参考になります。

リレーションも型付きで

from sqlalchemy.orm import relationship

class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    posts: Mapped[list["Post"]] = relationship(back_populates="user")

class Post(Base):
    __tablename__ = "posts"
    id: Mapped[int] = mapped_column(primary_key=True)
    user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
    user: Mapped["User"] = relationship(back_populates="posts")

Mapped[list["Post"]]で1対多、Mapped["User"]"で多対1を表現します。エディタ上でuser.posts[0].titleと打つと補完が効くので、タイポで消耗する時間が減りました。

MappedAsDataclassの落とし穴—`default`が予約語

2.0で追加されたMappedAsDataclassは、モデルをPython標準の@dataclassとして扱える機能です。__init__が自動生成されてUser(name="tanaka")のようなキーワード引数で作れます。便利ですが、1か所だけハマりやすい仕様があります。

`default`キーワードの衝突

dataclass側ではdefaultフィールドが予約語です。一方、mapped_columnにもdefaultパラメータがあり、両者を混同すると意図と違う動きになります

from sqlalchemy.orm import MappedAsDataclass, mapped_column

# NG: defaultをmapped_columnに渡しても、dataclassの初期化では必須扱い
class Article(MappedAsDataclass, Base):
    __tablename__ = "articles"
    id: Mapped[int] = mapped_column(primary_key=True, init=False)
    title: Mapped[str]
    status: Mapped[str] = mapped_column(default="draft")  # これがハマる
TypeError: Article.__init__() missing 1 required positional argument: 'status'
# mapped_columnのdefaultは「カラム定義時のデフォルト」であって、
# dataclassの__init__のデフォルト値ではない

正しい書き方

# OK: init側のデフォルトはinsert_defaultに分離する
class Article(MappedAsDataclass, Base):
    __tablename__ = "articles"
    id: Mapped[int] = mapped_column(primary_key=True, init=False)
    title: Mapped[str]
    # default: dataclassのデフォルト(__init__のデフォルト値)
    # insert_default: SQL INSERT時のデフォルト(カラム定義)
    status: Mapped[str] = mapped_column(default="draft", insert_default="draft")
a = Article(title="記事1")
print(a.status)  # → draft
session.add(a)
session.commit()
-- 発行されるSQL
INSERT INTO articles (title, status) VALUES ('記事1', 'draft');

公式のWhat's New in SQLAlchemy 2.0?New Dataclass Integrationセクションに明記されていますが、ここを読まずに使い始めて数時間溶かす人を何度か見ました。MappedAsDataclassを使うときはdefaultinsert_defaultのペアで書く、と覚えておくと安全です。

既存コードをどう移行するか

いきなり全部書き換えるのは現実的ではないので、段階的なアプローチを取ります。

ステップ1: 1.4最終版に上げて警告を出す

pip install "sqlalchemy==1.4.54"
SQLALCHEMY_WARN_20=1 python -W always::DeprecationWarning app.py
RemovedIn20Warning: The Query.get() method is deprecated. Use Session.get() instead.
RemovedIn20Warning: The legacy calling style of Session.query() is deprecated...

SQLALCHEMY_WARN_20=1という環境変数を立てると、2.0非互換の書き方がすべて警告として出ます。-W always::DeprecationWarningと組み合わせれば、無視されがちな警告も表示されます。まずはこの警告を1つずつ潰していくのが実用的です。

ステップ2: 2.0にアップグレード

pip install "sqlalchemy>=2.0"
pip show sqlalchemy | grep -i version
Version: 2.0.36

ステップ1で警告をほぼゼロにしてから上げれば、大きな事故は防げます。

ステップ3: モデル定義を順次書き換える

新規ファイルから新スタイル(Mapped + mapped_column)で書き、既存モデルは触るタイミングで更新するのが現実的です。1つのアプリ内で旧スタイルと新スタイルが共存しても動きます。

FastAPI と組み合わせる場合はFastAPIで作るREST API入門:Pythonで高速なAPI開発を始めようの構成にSQLAlchemy 2.0を足すだけでいいので、Pydantic 2と合わせて型安全な構成にできます。Pydantic側の詳細はPydantic V2のバリデーション設計—よくあるミスと正しい書き方に整理してあります。

まとめ

  • クエリAPI: session.query()Legacy扱いだが廃止はされない。新規コードはsession.execute(select(...))で統一
  • モデル定義: Columnよりmapped_columnを推奨。Mapped[int]で型ヒントが公式サポート
  • 非NULL表現: Mapped[str](必須)とMapped[str | None](NULL許容)を使い分ける
  • MappedAsDataclass: defaultはdataclass用、insert_defaultはカラム定義用。両方書くのが安全
  • 段階移行: SQLALCHEMY_WARN_20=1で警告を出しながら1.4最終版で潰す → 2.0へ → モデル更新、の順で
  • 公式資料: What's New in SQLAlchemy 2.0?SQLAlchemy 2.0 - Major Migration Guideは移行時に必ず一読しておくとハマりが減る
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次