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.1(Python 3.13.14・SQLite 3.53.1、2026年9月26日実行)の実行結果で確かめます。
まとめ:ORMの要点
- ORMは「クラス=テーブル、インスタンス=行、属性=列」を基本にオブジェクトとテーブルを対応づけ、SQLを自動生成する技術です。読み方は「オーアールエム」で、ツールを指してO/Rマッパーとも呼びます。
- 主な利点は、ORMの比較式を使えば値がバインド変数で渡ること、DBごとのSQL方言の差を吸収すること、変更の追跡とまとめ書き込み(Unit of Work)を任せられることです。
- 代表的な弱点はN+1問題です。SQLAlchemyで著者100人とその本を既定設定のまま読むと101本のSELECTが走り、
selectinloadを1行足すと2本に減りました。 - ORMを使ってもSQLインジェクションは消えません。生SQLへの文字列埋め込みと、バインドできない並べ替え列名が抜け穴として残ります。
- CRUD中心の業務アプリはORM、集計・分析・大量一括更新はSQL直書きかクエリビルダ、と処理単位で使い分けるのが現実的です。
ORMの意味と読み方・O/Rマッパーとの関係
ORMは「Object-Relational Mapping(オブジェクト関係マッピング)」の略で、オブジェクトとテーブルを対応づける技術を指します。英字のまま「オーアールエム」と読みます。マッピングという考え方を指すときはORMやO/Rマッピング、それを実装したライブラリを指すときはO/RマッパーやORマッパーと呼ぶことが多く、日常的にはほぼ同じ意味で使われます。
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の基本と使い方を入門から実践まで解説とAlembic(アレンビック)とは?Python/SQLAlchemyのDBマイグレーションツールの使い方を基礎から解説で詳しく扱っています。
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.1での実測結果です。
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を使うメリット
- 比較式の値はバインド変数で渡る:前述の実行結果のとおり、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直書き |
| 個別オブジェクトの大量更新が遅い | 1行ずつ読み込んで属性を変更 | 一括UPDATE文を発行 |
| 学習コストの二重化 | ORMのAPIとSQLの両方が必要 | 生成SQLを読む習慣 |
N+1問題は、一覧を1本のSQLで取った後、各行の関連データを1本ずつ取りに行く現象です。著者100人に3冊ずつ本を持たせ、SQLAlchemy 2.1.1で全員の本の冊数を数えるコードを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"
# 危険: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)"
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の出口と、並べ替え・列選択のパラメータをコードレビューの確認項目に入れてください。Java(MyBatis)での動的SQLの注意点はMyBatis Dynamic SQL(動的SQL)の書き方|if・foreach・whereとインジェクション対策で扱っています。
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年版】、TypeScriptはTypeORMとPrismaを比較|2026年版の違いと選び方(型安全・マイグレーション・パフォーマンス)とDrizzle ORM(ドリズル)とは?使い方・Prismaとの比較・v1.0 RCの変更点【2026年9月】、DjangoはDjango ORMのクエリ設計|遅延評価と集計の順序・生SQLへ落とす判断で詳しく扱っています。
Goのdatabase/sql・sqlx・sqlc・GORM・entの位置づけ
Goは標準ライブラリのdatabase/sqlが「SQL(またはSQLに似た)データベースへの汎用インターフェース」を提供し、その上に性格の違うライブラリが並びます。2026年9月26日時点の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の学習コストと抽象化の層だけが残ります。
避けたいのは、集計画面をORMのオブジェクトで組み、全件を読み込んだうえで関連をたどりながらアプリ側のループで合計する書き方です。関連の読み込み方を指定していなければ行数分のSELECTが加わり、全件をオブジェクト化する分のメモリも消費します。件数が少ない開発環境では問題が表に出ず、本番データで初めて表面化します。SQLAlchemyなら同じセッションの中でtext()(バインド変数付き)やCoreのselect()に切り替えられるので、トランザクションを分けずに部分的にSQLへ降りられます。
よくある質問
ORMの読み方は?
英字のまま「オーアールエム」と読みます。Object-Relational Mappingの略で、日本語ではオブジェクト関係マッピング、ツールを指す場合はO/Rマッパー(オーアールマッパー)とも呼びます。
ORMを使えばSQLは覚えなくてよいですか?
いいえ。N+1問題の検出や遅いクエリの調査では、ORMが生成したSQLを読む必要があります。集計やDB固有機能を使う処理はSQLで書く場面も残るため、少なくともSELECT・JOIN・インデックスの基本は必要です。
DAOとORMの違いは?
DAOはデータベースアクセスを1つのクラスの背後に隠す設計上の層で、ORMはオブジェクトとテーブルを対応づけるライブラリです。DAOの内部実装としてORMを使うことも、SQL直書きを使うこともできます。
PythonのORMは何を選べばよいですか?
Djangoでアプリを作るならDjangoに同梱のDjango ORMを使います。FastAPIやFlaskなどフレームワークを問わず使うなら、Data Mapper型のSQLAlchemyが定番です。SQLAlchemyの最新は2.1.1(2026年9月25日公開)で、Python 3.11以上が必要です。
ORMはNoSQLでも使えますか?
ORMの「R」はリレーショナルを指すため、MongoDBのようなドキュメント型DBでは、同じ役割のライブラリをODM(Object-Document Mapper)と呼び分けることがあります。Node.jsのMongooseが代表例で、自らを「MongoDB object modeling」と説明しています。