ORM(Object-Relational Mapping)は、プログラムの中のオブジェクトとリレーショナルデータベースのテーブルを対応づけ、SQLの組み立てと結果の詰め替えを肩代わりする仕組みです。select(User).where(User.name == "sato")のような式からSELECT文が組み立てられ、book.authorのように関連をたどる属性を読んだ時点でSQLが発行されることもあります。
本記事では、同じ処理をSQL直書きとORMで書き比べ、ORMの内部で起きていること、Active RecordとData Mapperの違い、N+1問題やSQLインジェクションの抜け穴を、SQLAlchemy 2.1.6(Python 3.13.14・SQLite 3.53.1、2026年10月27日実行)の実行結果で確かめます。1万行の一括更新と集計をORMのオブジェクトで1件ずつ処理した場合の時間とメモリも測り、DB側の一括処理に切り替える目安を数値で示します。
まとめ:ORMの要点
- ORMは「クラス=テーブル、インスタンス=行、属性=列」を基本にオブジェクトとテーブルを対応づけ、SQLを自動生成する技術です。読み方は「オーアールエム」で、ツールを指してO/Rマッパーとも呼びます。
- 主な利点は、ORMの比較式を使えば値がバインド変数で渡ること、DBごとのSQL方言の差を吸収すること、変更の追跡とまとめ書き込み(Unit of Work)を任せられることです。
- 代表的な弱点はN+1問題です。SQLAlchemyで著者100人とその本を既定設定のまま読むと101本のSELECTが走り、
selectinloadを1行足すと2本に減りました。 - ORMを使ってもSQLインジェクションは消えません。生SQLへの文字列埋め込みと、バインドできない並べ替え列名が抜け穴として残ります。
- 1万行の値上げ処理は、ORMのオブジェクトを1件ずつ書き換えると約0.66秒・メモリ24MB、
UPDATE文1本なら約0.004秒でした。CRUD中心の処理はORM、集計・一括更新はSQL(またはクエリビルダ)と、処理単位で使い分けるのが現実的です。
ORMの意味と読み方・O/Rマッパーとの関係
ORMは「Object-Relational Mapping(オブジェクト関係マッピング)」の略で、オブジェクトとテーブルを対応づける技術を指します。英字のまま「オーアールエム」と読みます。マッピングという考え方を指すときはORMやO/Rマッピング、それを実装したライブラリを指すときはO/RマッパーやORマッパーと呼ぶことが多く、日常的にはほぼ同じ意味で使われます。なお、安全管理やマーケティングの文脈の「ORM」は別の語の略で、本記事の対象外です(末尾のよくある質問で触れます)。
ORMが埋めるのは、オブジェクト指向の言語とリレーショナルデータベースの表現の違いです。この違いはインピーダンスミスマッチと呼ばれます。プログラムは「注文オブジェクトが明細のリストを持つ」と参照でつながりますが、データベースは明細テーブルが注文IDを外部キーとして持ち、結合(JOIN)でつなぎます。基本的な対応は次の表のとおりです(継承を使うモデルなどでは、1クラスが複数テーブルにまたがる対応もあります)。
| プログラム側 | データベース側 | 例 |
|---|---|---|
| クラス | テーブル | User と users |
| インスタンス | 1行(レコード) | id=1の利用者 |
| 属性 | 列(カラム) | user.email |
| オブジェクトの同一性 | 主キー | 1つのセッション内でid=1は1つのオブジェクト |
| 他オブジェクトへの参照 | 外部キーと結合 | book.author |
| 子オブジェクトのリスト | 1対多の子テーブル | author.books |
テーブル設計そのもの(どこまで分割するか)はORMの守備範囲外です。分割の考え方はデータベース正規化とは?第1〜第3正規形の手順と非正規化の判断を解説で扱っています。
SQL直書きとORMの書き方の違い
利用者を1件登録して名前で検索する処理を、Python標準のsqlite3(SQL直書き)とSQLAlchemy 2.1のORMで書き比べます。
import sqlite3
# SQL直書き:SQLを自分で書き、結果の列を1つずつ詰め替える
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)")
conn.execute("INSERT INTO users (name, email) VALUES (?, ?)", ("sato", "[email protected]"))
row = conn.execute("SELECT id, name, email FROM users WHERE name = ?", ("sato",)).fetchone()
user = {"id": row[0], "name": row[1], "email": row[2]}
from sqlalchemy import create_engine, select, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(50))
email: Mapped[str] = mapped_column(String(100))
engine = create_engine("sqlite://", echo=True) # echo=Trueで発行SQLを表示
Base.metadata.create_all(engine)
with Session(engine) as session:
session.add(User(name="sato", email="[email protected]"))
session.commit()
user = session.scalars(select(User).where(User.name == "sato")).one()
print(user.id, user.email) # 1 [email protected]
echo=Trueで表示されたSQLは次のとおりです(ログの接頭辞は省略)。
CREATE TABLE users (
id INTEGER NOT NULL,
name VARCHAR(50) NOT NULL,
email VARCHAR(100) NOT NULL,
PRIMARY KEY (id)
)
INSERT INTO users (name, email) VALUES (?, ?)
[generated in 0.00031s] ('sato', '[email protected]')
SELECT users.id, users.name, users.email
FROM users
WHERE users.name = ?
[generated in 0.00033s] ('sato',)
User.name == "sato"という比較式がWHERE users.name = ?へ変換され、値は別枠のパラメータとして渡されています。型ヒントをMapped[str](Optionalなし)にした列は自動でNOT NULLになりました。そして結果は辞書への詰め替えなしにUserオブジェクトとして返ります。SQL直書き側のrow[0]のような列番号への依存が消えるのが、ORMの最も分かりやすい効果です。
SQLAlchemyの導入や、テーブル定義の変更をDBに反映する手順はSQLAlchemyとは?読み方・使い方とPython ORMの基本を2.1.5の実行結果で解説とAlembic(アレンビック)とは?SQLAlchemyのマイグレーションの使い方とautogenerateの落とし穴【1.20対応】で詳しく扱っています。
ORMの内部の仕組み:Identity Map・Unit of Work・Lazy Load
ORMの動きの多くは、Martin Fowlerが『Patterns of Enterprise Application Architecture』(2002年)で名前を付けた3つのパターンで説明できます(定義はFowlerのパターンカタログで公開されています)。SQLAlchemyのREADMEも、ORMが「identity map, unit of work, and data mapper patterns」の上に作られていると明記しています。以下、各パターンの定義とSQLAlchemy 2.1.6での実測結果です。
Identity Mapは、Fowlerの定義では「読み込んだオブジェクトをすべてマップに保持し、各オブジェクトが一度だけ読み込まれることを保証する」ものです。同じセッション内でsession.get(Author, 1)を2回呼ぶと、発行されたSQLは1本で、2つの戻り値はisで比較して同一でした。1つのセッションの中で同じ行が別々のオブジェクトになり、片方の変更がもう片方に反映されない、という食い違いをこれで防いでいます。ただしクエリ結果のキャッシュではないため、select()で同じ条件の検索を繰り返せばSQLはその都度発行されます。session.getが2回目にSQLを省けたのは、主キーで引く場合にIdentity Mapを先に確認する仕様だからです。
Unit of Workは、業務処理で変更されたオブジェクトの一覧を保持し、書き込みを取りまとめます。author.name = "renamed"と代入した時点ではSQLは発行されず、commit()でUPDATEが1本だけ流れました。書き込みはsession.flush()で明示的に前倒しできるほか、既定のautoflush=Trueではクエリを実行する直前にも自動で行われます。開発者がUPDATE文を書かなくてよい代わりに、SQLが流れる時点を意識しておく必要があります。
Lazy Loadは、関連オブジェクトを実際に使う時点まで読み込みを遅らせます。SQLAlchemyのドキュメントは、lazyを指定しないrelationship()の既定をlazy loadingとしており、手元でもAuthor.books.property.lazyはselectでした。この既定が、次に述べるN+1問題の原因になります。
Active RecordとData Mapper:ORMの2つの設計パターン
ORMは、データベースへの保存処理をどこに置くかで2つの型に分かれます。Active Recordは「テーブルの1行を包み、データベースアクセスとその行に対する業務ロジックを1つのオブジェクトに持たせる」型、Data Mapperは「オブジェクトとデータベースの間でデータを運ぶマッパー層を置き、両者を互いに独立させる」型です(いずれもFowlerの定義)。
| 項目 | Active Record | Data Mapper |
|---|---|---|
| 保存の書き方 | user.save() |
session.add(user) |
| モデルの責務 | データ+DBアクセス | データ(+業務ロジック)のみ |
| 代表例 | Rails Active Record、Django ORM | SQLAlchemy ORM |
| 向く規模 | 画面とテーブルが素直に対応する | 業務ルールが複雑なドメイン |
Rails Guidesは自らをActive Recordパターンの実装と説明しており、Djangoの設計思想のページも「Martin FowlerのActive Recordデザインパターンに従う」と書いています。TypeORMはREADMEで両方のパターンに対応すると明記している珍しい例です。Laravel Eloquentも1テーブルに1モデルが対応するActive Record型です。
業務ルールをDBと切り離してテストしたいならData Mapper、書き始めの速さを優先するならActive Recordが目安です(後者はモデルが肥大化しやすい)。Rails側から見た詳細はActive Record(アクティブレコード)とは?Railsを支える設計パターンと基本を実例で解説、EloquentはLaravel Eloquentとは|モデル定義・リレーション・eager loadingの基礎とクエリビルダの使い分けを参照してください。
ORMの歴史:Hibernate・JPA・Rails Active Recordと「ORMはベトナム」論争
ORMは特定の製品名ではなく、2000年代にJavaとRubyの世界で標準的な道具になった技術の総称です。主な出来事を一次情報の日付で並べます。
| 年月日 | 出来事 |
|---|---|
| 2002年 | Fowlerが『PoEAA』でActive Record・Data Mapper等を命名 |
| 2005-12-13 | Ruby on Rails 1.0公開(Active Record 1.13.2を同梱) |
| 2006-05-11 | JSR 220(EJB 3.0)最終リリース。JPA 1.0を含む |
| 2006-06-26 | Ted Newardが「The Vietnam of Computer Science」を公開 |
| 2012-05-08 | Fowlerが「OrmHate」を公開 |
| 2024-04-30 | Jakarta Persistence 3.2リリース |
Javaでは、Gavin Kingが創始したHibernateなどのO/Rマッパーが使われる中で、Java Persistence API(JPA)がJSR 220(EJB 3.0)の一部として2006年に標準化されました。JPAはその後Eclipse Foundationへ移ってJakarta Persistenceと名前を変え、3.0でパッケージがjavax.*からjakarta.*へ移りました。2026年10月27日時点の公開済みの最新は3.2で、4.0は開発中です。Hibernateは独自APIと並んでJakarta Persistenceの実装を提供しており、OracleのTopLinkも11g Release 1からEclipseLinkを中核に組み込んでいます。エンティティの書き方はエンティティクラスとは?JPAとEF Coreの書き方・DTOとの違いを実行結果で解説で扱っています。Rubyでは、2005年12月13日に公開されたRails 1.0がActive Recordを同梱し、「モデルのクラスを書けばテーブルを読み書きできる」作法を広めました。
普及と同時に批判も生まれました。Ted Newardは2004年のTechEdで「Object/relational mapping is the Vietnam of Computer Science」と言い出し、2006年6月26日のエッセイで、ORMは始めこそ順調でも次第に複雑になり、撤退の基準が無いまま抜け出せなくなる泥沼だと論じました。これに対しFowlerはOrmHate(2012年5月8日)で、対応づけの問題そのものが難しいと認めたうえで「So ORMs help us deal with a very real problem for most enterprise applications.」と書き、自前でORMを作るのは避けるべきだと反論しています。同じ記事でFowlerは、ORMが対応づけの問題の8〜9割を処理できる一方、残りの部分にはリレーショナルデータベースをよく理解した人の慎重な作業が要るとも書いています。本記事が後半で「ORMで書く処理とSQLに降りる処理を分ける」ことを勧めるのは、この残りの部分の扱い方についての判断です。
ORMを使うメリット
- 比較式の値はバインド変数で渡る:前述の実行結果のとおり、ORMの比較式に渡した値はSQL文字列に埋め込まれず
?の別枠で渡ります(生SQLを組み立てる経路は例外で、後述します)。OWASPのSQLインジェクション対策チートシートも、新規開発では「パラメータ化クエリ、ストアドプロシージャ、またはクエリを組み立てるORM」で作るよう勧めています。 - SQL方言の差を吸収できる:同じ
select(User).limit(10)を方言ごとにコンパイルすると、SQLiteとPostgreSQL・MySQLではLIMIT、SQL ServerではSELECT TOP、OracleではFETCH FIRST … ROWS ONLYに変換されました。ただし型や照合順序などDBごとの挙動の差は残るため、本番と同じDBでのテストは別途必要です。 - 型と補完が効く:
Mapped[int]のような型付きの列定義により、列名の打ち間違いが実行前にエディタや型チェッカーで見つかります。SQL文字列の中の列名は、SQL対応のIDEやスキーマを参照する解析ツールを使わない限り、実行時まで誤りに気づきにくくなります。 - マイグレーションと連携できる:モデル定義の変更からスキーマ変更スクリプトを生成するツールと組み合わせられます。比較の相手は異なり、AlembicはDBの現状とモデルを、Djangoの
makemigrationsは過去のマイグレーションファイルから復元した状態と現在のモデルを比べます。
ORMのデメリットとN+1問題の実測
ORMの弱点の多くは「SQLが見えなくなること」から生じます。
| 弱点 | 起きること | 対処 |
|---|---|---|
| N+1問題 | 関連の遅延読み込みでSELECTが件数分増える | Eager Loadを明示 |
| 発行SQLが見えない | 遅いSQLに気づけない | SQLログ・件数テスト |
| 集計・分析が苦手 | ウィンドウ関数やCTEが書きにくい | その部分だけSQL直書き |
| 一括更新・集計が遅い | 全行をオブジェクト化してから処理 | UPDATE文・集計関数を発行 |
| 学習コストの二重化 | ORMのAPIとSQLの両方が必要 | 生成SQLを読む習慣 |
N+1問題は、一覧を1本のSQLで取った後、各行の関連データを1本ずつ取りに行く現象です。著者100人に3冊ずつ本を持たせ、SQLAlchemy 2.1.6で全員の本の冊数を数えるコードを3通りに書き、発行されたSQLの本数を数えました。モデル定義から計測までを含む実行可能なコードです。
from sqlalchemy import create_engine, event, select, text, ForeignKey, String
from sqlalchemy.orm import (DeclarativeBase, Mapped, mapped_column, relationship,
Session, selectinload, joinedload)
class Base(DeclarativeBase):
pass
class Author(Base):
__tablename__ = "authors"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(50))
books: Mapped[list["Book"]] = relationship(back_populates="author")
class Book(Base):
__tablename__ = "books"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(100))
author_id: Mapped[int] = mapped_column(ForeignKey("authors.id"))
author: Mapped["Author"] = relationship(back_populates="books")
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
sql_log = [] # 発行されたSQLを記録する
@event.listens_for(engine, "before_cursor_execute")
def record(conn, cursor, statement, parameters, context, executemany):
sql_log.append(statement)
with Session(engine) as session: # 著者100人×3冊を投入
for i in range(100):
session.add(Author(name=f"author{i}",
books=[Book(title=f"book{i}-{j}") for j in range(3)]))
session.commit()
def count_books(option=None):
sql_log.clear()
with Session(engine) as session:
stmt = select(Author)
if option is not None:
stmt = stmt.options(option)
authors = session.scalars(stmt).unique().all()
total = sum(len(a.books) for a in authors)
return len(sql_log), total
print(count_books()) # (101, 300) 既定のlazy="select"
print(count_books(selectinload(Author.books))) # (2, 300)
print(count_books(joinedload(Author.books))) # (1, 300)
| 読み込み方 | 発行SQL数 | 2本目以降のSQL |
|---|---|---|
| 既定(lazy) | 101本 | 著者ごとにWHERE author_id = ? |
selectinload |
2本 | WHERE author_id IN (?, …)(100個) |
joinedload |
1本 | なし(LEFT OUTER JOIN books) |
どの書き方でも数えた冊数は300冊で、結果は同じです。一方でSQLの本数に加え、結果の行の形(joinedloadは著者の列が本の数だけ繰り返される)や転送量も異なります。既定の書き方は読み込み方法を何も指定しないため、コードを読むだけではN+1に気づきにくいのが問題です。著者が1万人になれば既定の書き方は10,001本に増えます。一覧画面を作ったら、発行SQLの本数をテストで固定しておくのが確実な予防策です。Django・Rails・Hibernateでの対策と検出ツールはN+1問題とは?発行クエリ数の実測と検出方法・ORM別の対策にまとめています。
ORMでも残るSQLインジェクションの抜け穴
ORMの比較式を使う限り値はバインドされますが、ORMには生SQLを書く出口が必ずあります。OWASPのチートシートも、HibernateのHQLに「同じ種類のインジェクション問題(HQL Injection)」があると注意しています。前述のスクリプトの続きとして、名前に' OR '1'='1を入力された場合を試しました(textは冒頭でimport済み)。
inp = "' OR '1'='1"
with Session(engine) as session:
# 危険:text()にf文字列で埋め込む → 100行すべて返った
session.scalars(select(Author).where(text(f"name = '{inp}'"))).all()
# 安全:text()でもバインド変数を使う → 0行
session.scalars(select(Author).where(text("name = :n")).params(n=inp)).all()
# 安全:ORMの比較式 → 0行
session.scalars(select(Author).where(Author.name == inp)).all()
見落としやすいのは並べ替えです。OWASPは、テーブル名・列名・並び順(ASC/DESC)はバインド変数にできないため、許可リストでの入力検証か設計の見直しが必要だとしています。URLの?sort=をそのままorder_by(text(sort))に渡すと、次の入力で他の行の中身を1文字ずつ推測できました。
# 条件が真なら id昇順(先頭 id=1)、偽なら降順(先頭 id=100)
sort = "(CASE WHEN (SELECT substr(name,1,6) FROM authors WHERE id=1)='author' THEN id ELSE -id END)"
with Session(engine) as session:
session.scalars(select(Author).order_by(text(sort)).limit(1)).first().id # 1
# 'author' を 'xxxxxx' に変えると 100 になる
# 対策:受け付ける値を辞書で列オブジェクトに対応づける
ALLOWED = {"name": Author.name, "id": Author.id}
col = ALLOWED.get(sort, Author.id) # 想定外の値は既定列に倒す
session.scalars(select(Author).order_by(col)).all()
先頭行が1になるか100になるかで、SQLの条件の真偽が外から分かり、エラーも出ません。ORMを採用しただけでインジェクション対策済みとはせず、text()・raw()・Sequelize.literal()のような生SQLの出口と、並べ替え・列選択のパラメータをコードレビューの確認項目に入れてください。Node.jsのSequelizeで2026年に公表されたSQLインジェクションと修正版はSequelizeとはで扱っています。Java(MyBatis)での動的SQLの注意点はMyBatis Dynamic SQLの使い方|if・where・foreachと2.0.0の型安全DSLを実行結果で解説で扱っています。
ORM・クエリビルダ・SQLマッパー・DAOの違い
「ORMかSQL直書きか」の間には、SQLをどこまで自分で書くかで段階があります。
| 方式 | SQLを書くのは | 結果の受け取り | 例 |
|---|---|---|---|
| フルORM | ORM(自動生成) | モデルのオブジェクト | SQLAlchemy ORM、Hibernate、Prisma |
| クエリビルダ | コードで組み立て | 行・型付きの値 | SQLAlchemy Core、Kysely |
| SQLマッパー | 開発者(SQL文) | 指定したクラス | MyBatis、Dapper、sqlx |
| SQLからのコード生成 | 開発者(SQL文) | 生成された型 | sqlc |
| SQL直書き | 開発者 | 行(自分で詰め替え) | JDBC、database/sql |
MyBatisは自らを「SQL mapper framework」、Dapperは「a simple object mapper」、Kyselyは「type-safe TypeScript SQL query builder」と説明しています。SQLは自分で書き、結果の詰め替えだけを任せたい場合はSQLマッパーが、SQLの構造を保ったまま型の恩恵を受けたい場合はクエリビルダやコード生成が選択肢になります。
DAO(Data Access Object)はこれらと同列の選択肢ではなく、データベースアクセスを1つのクラスの背後に隠す設計上の層です。DAOの中身をSQL直書きで実装することも、ORMで実装することもできます。「DAOかORMか」ではなく、「DAOの中で何を使うか」と考えると整理しやすくなります。
言語別の代表的なORM
| 言語 | 代表的なORM | 補足 |
|---|---|---|
| Java | Hibernate(Jakarta Persistence) | SQLマッパーならMyBatis |
| Python | SQLAlchemy、Django ORM | Django ORMはActive Record型 |
| Ruby | Active Record | Railsに同梱 |
| PHP | Eloquent(Laravel) | 1テーブル1モデル |
| TypeScript | Prisma、TypeORM、Drizzle ORM | TypeORMは2パターン両対応 |
| C# | Entity Framework Core | 軽量な選択肢にDapper |
| Go | GORM、ent | sqlx・sqlcとの比較は下記 |
言語ごとの選び方は、JavaはJava ORM(O/Rマッパー)の種類と選び方|Hibernate・MyBatis・jOOQを比較【2026年10月】、TypeScriptはTypeORMとPrismaを比較|2026年版の違いと選び方(型安全・マイグレーション・パフォーマンス)・TypeORMとは?1.1系の使い方|エンティティ・CRUD・リレーション・マイグレーションを実行して解説・Drizzle ORM(ドリズル)とは?使い方・Prismaとの比較・v1.0 RCの変更点【2026年10月】、DjangoはDjango ORMのクエリ設計|遅延評価と集計の順序・生SQLへ落とす判断で詳しく扱っています。
Goのdatabase/sql・sqlx・sqlc・GORM・entの位置づけ
Goは標準ライブラリのdatabase/sqlが「SQL(またはSQLに似た)データベースへの汎用インターフェース」を提供し、その上に性格の違うライブラリが並びます。2026年10月27日時点のGitHubの最新リリースで整理します。
| ライブラリ | 分類 | 最新リリース |
|---|---|---|
| database/sql | 標準の汎用インターフェース | Go本体に同梱 |
| sqlx | database/sqlの拡張(SQLマッパー) | v1.4.0(2024-04-23) |
| sqlc | SQLから型付きコードを生成 | v1.31.1(2026-04-22) |
| GORM | フルORM | v1.31.2(2026-06-25) |
| ent | スキーマをGoコードで書くエンティティフレームワーク | v0.14.6(2026-03-23) |
| SQLBoiler | DBスキーマからORMを生成 | v4.19.5(2025-06-27) |
SQLBoilerは、GitHubのREADMEで「maintenance mode」に入ったことを宣言しています。新機能は原則受け付けず、報告された不具合もメンテナーが通常は対応しないと書かれ、代替としてBobとsqlcを挙げています。これから新規に選ぶなら、SQLを自分で書くチームはsqlc、モデル中心で書きたいチームはGORMかent、が2026年時点の現実的な分岐です。GORMの一括挿入とトランザクションの範囲はGORM CreateInBatchesの一括挿入とトランザクション範囲|v1.31.2の基本操作、ジェネリクス対応の新APIはGORMのGenerics APIとは?従来APIとの違いと基本の実装パターンで解説しています。
ORMを使うべき処理とSQL直書きに切り替える判断基準
ORMは全面採用か不採用かの二択ではなく、1つのアプリの中で処理の性格ごとに切り替えられます。
処理の性格で決める切り替えの基準
- ORMで書く:1件〜数十件のCRUD、画面とテーブルが素直に対応する管理画面、関連をたどる業務処理。Identity MapとUnit of Workの恩恵が最も大きい領域です。
- SQL直書き(またはクエリビルダ)に切り替える:売上集計・ランキングのようにウィンドウ関数やCTEを使う分析系、条件に合う行をまとめて書き換える一括更新、PostgreSQLのJSONB演算子や全文検索などDB固有の機能を使う処理。
- ORMを採用しない:ほぼすべての処理が集計・レポートで、モデルとして扱う対象が少ないアプリ。ORMの学習コストと抽象化の層だけが残ります。
SQLAlchemyなら同じセッションの中でtext()(バインド変数付き)やCoreのupdate()・select()に切り替えられるので、トランザクションを分けずに部分的にSQLへ降りられます。
1万行の一括更新と合計で測ったオブジェクト単位の処理とDB側の一括処理の差
「一括更新と集計はDB側でまとめて行う」がどの程度の差になるのかを測りました。価格1,000の商品1万行を1.1倍にする処理と、全行の価格を合計する処理を、ORMのオブジェクトを1件ずつ扱う方法と、DBに1本の文で処理させる方法で比べています。後者もSQLAlchemyのORM対応のupdate()とfunc.sum()を使っており、公式ドキュメントがORM-Enabled UPDATEとして案内している書き方です。ORMをやめなくても、DB側の一括処理へ切り替えられます。
from sqlalchemy import create_engine, select, update, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
class Base(DeclarativeBase):
pass
class Item(Base):
__tablename__ = "items"
id: Mapped[int] = mapped_column(primary_key=True)
price: Mapped[int]
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as session:
session.execute(Item.__table__.insert(), [{"price": 1000} for _ in range(10000)])
session.commit()
def orm_loop(): # オブジェクトを全件読み、属性を書き換える
with Session(engine) as session:
for item in session.scalars(select(Item)):
item.price = item.price * 11 // 10
session.commit()
def one_update(): # UPDATE文を1本だけ発行する
with Session(engine) as session:
session.execute(update(Item).values(price=Item.price * 11 // 10))
session.commit()
def sum_in_python():
with Session(engine) as session:
return sum(item.price for item in session.scalars(select(Item)))
def sum_in_sql():
with Session(engine) as session:
return session.scalar(select(func.sum(Item.price)))
| 処理(1万行) | 書き方 | 時間 | メモリのピーク | 発行SQL |
|---|---|---|---|---|
| 1.1倍に更新 | ORMのループ | 0.66秒 | 24.1MB | SELECT+UPDATE×1万件分 |
| 1.1倍に更新 | update() |
0.0037秒 | 0.02MB | UPDATE 1本 |
| 合計 | Pythonでsum |
0.15秒 | 12.6MB | SELECT 1本(全行) |
| 合計 | func.sum |
0.0018秒 | 0.01MB | SELECT 1本(1行) |
時間は5回実行の中央値で、各回の前に全行の価格を1,000へ戻しています(掲載コードの関数を続けて呼ぶと、2回目は1,210になります)。メモリは各処理の直前にtracemallocで追跡を始めて測った、Pythonが確保したメモリのピークで、DBやネイティブ側の使用量は含みません(Intel Core i9-9880H・インメモリのSQLite)。更新後の値はどちらも全行1,100で、結果は同じでした。ORMのループ側もUPDATEは1回のexecutemanyにまとめて送られていましたが、1万件分のパラメータを組み立てる前に1万個のオブジェクトを作り、変更を追跡する費用がかかります。ネットワーク越しのDBなら全行をアプリへ転送する時間も加わります。
件数が少ない開発環境では0.1秒前後の差は目立たず、本番データで初めて表面化します。行数に比例して伸びる処理(全件の書き換え・合計・件数集計)では、全行をアプリへ転送してオブジェクト化していないかをまず確かめ、DB側の一括処理に置き換えたら同じデータ量で測り直して効果を確かめてください。既存システムでどの処理をSQLへ降ろすか、テーブル設計から見直したい場合はデータベース設計・移行支援で相談を受けています。
よくある質問
ORMの読み方は?
英字のまま「オーアールエム」と読みます。Object-Relational Mappingの略で、日本語ではオブジェクト関係マッピング、ツールを指す場合はO/Rマッパー(オーアールマッパー)とも呼びます。
ビジネスの文脈でいうORMとは何ですか?
データベースとは別の語の略です。安全管理では、FAAの内部規程Order VN 3900.1(2004年4月30日)がOperational Risk Management(ORM)を「リスクを検出・評価・管理する継続的なプロセス」と定義しています。マーケティングでは、ネット上の評判を監視して働きかけるOnline Reputation Managementの略として使われます。
ORMを使えばSQLは覚えなくてよいですか?
いいえ。N+1問題の検出や遅いクエリの調査では、ORMが生成したSQLを読む必要があります。集計やDB固有機能を使う処理はSQLで書く場面も残るため、少なくともSELECT・JOIN・インデックスの基本は必要です。
DAOとORMの違いは?
DAOはデータベースアクセスを1つのクラスの背後に隠す設計上の層で、ORMはオブジェクトとテーブルを対応づけるライブラリです。DAOの内部実装としてORMを使うことも、SQL直書きを使うこともできます。
ORMとORDBMSやNoSQLとの関係は?
ORDBMS(オブジェクトリレーショナルデータベース)は、ユーザー定義型や継承などの機能を持つデータベース製品の分類で、ORMはアプリ側のライブラリです。PostgreSQLの公式マニュアルは自らをORDBMSと説明していますが、ORDBMSかどうかとORMを使うかどうかは別の判断で、PostgreSQLもドライバ経由でSQLを直接実行できます。MongoDBのようなドキュメント型DBでは、同じ役割のライブラリをODM(Object-Document Mapper)と呼び分けることがあり、Node.jsのMongooseが代表例です。