データベース

ORMとは?O/Rマッパーの仕組み・メリットとデメリットをSQL比較と実行結果で解説

ORMとは?O/Rマッパーの仕組み・メリットとデメリットをSQL比較と実行結果で解説

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」と説明しています。

関連記事

お気に入りに入れた記事の一覧

この記事は以下の記事からリンクされています

ほか 4 件の記事からもリンクされています。

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.10.07 テックブログ ファインズの不正アクセスと153万件の予約者情報|予約システムを使う店舗の報告と通知の手順
  2. 2026.10.19 テックブログ コミックシーモア不正アクセスで会員情報流出:再開時期・パスワード再設定と運営側のログ点検
  3. 2026.10.16 テックブログ バーガーキング情報漏洩どうしたらいい?約1,118万件の対象確認・補償・退会とメールの真偽
  4. 2026.10.15 テックブログ リゾートトラスト不正アクセス:会員2.6万件・カハラ横浜3.6万件の対象確認と窓口
  5. 2026.10.15 テックブログ 銀のさら不正アクセス:最大650万件の対象確認と窓口、パスワード変更・退会の手順

RELATED POSTS 関連記事

目次