はじめに
SQLに慣れた状態でSalesforceの SOQL(Salesforce Object Query Language) を書き始めると、多くの人が最初にこう感じると思います。
「あれ、JOIN が書けない……?」
SELECT も WHERE も ORDER BY もあるのに、JOIN ... ON だけが見当たりません。最初は「機能が足りないSQL」に見えるのですが、調べていくと 「JOINがない」のではなく「JOINの代わりにリレーションをたどる言語」 と捉えたほうが理解しやすいと感じました。
本記事では、次の観点で整理します。
- SQLとSOQLの根本的な考え方の違い
- 子→親(ドット表記)と親→子(サブクエリ)の2つのたどり方
- SQLの JOIN / EXISTS / SELECT * を SOQL でどう読み替えるか
- SQL経験者がつまずきやすいポイント
※本記事は個人の整理メモです。内容は2026-10-13時点で確認した公式ドキュメントをもとにしていますが、上限値などはAPIバージョンで変わることがあるため、実装時は必ず公式ドキュメントを確認してください。
1. まず全体像:表をつなぐか、関係をたどるか
SQLのJOINでは、2つのテーブルをどうつなぐかを クエリを書く人が ON 句で決めます。この例では、親の値が子の行ごとに繰り返される フラットな表 になります。
-- SQL:結合条件はクエリ側で指定
SELECT a.name, c.last_name
FROM account a
JOIN contact c ON c.account_id = a.id;
一方SOQLでは、オブジェクト同士のつながりは 項目定義(参照関係・主従関係)として、あらかじめスキーマ側に定義 されています。クエリはその定義済みのリレーションを「名前でたどる」だけです。
-- SOQL:定義済みのリレーション名(Contacts)でたどる
SELECT Name, (SELECT LastName FROM Contacts)
FROM Account
この親→子のクエリでは、親レコードの中に子レコードのリストが入れ子になったオブジェクトの形 で返ります(ApexならAccountのsObjectに Contacts のリストがぶら下がる形)。
※図1と上のコードは、結果の構造を比較する例です。SQL例の JOIN は内部結合なのでContactのないAccountを除外しますが、SOQL例は子のないAccountも返します。取得対象まで完全に同じになるわけではありません。
👉 SQLのJOINは「表をつなぐ」、SOQLのリレーションクエリは「オブジェクトの関係をたどる」と整理すると、書き方の違いを理解しやすそうです。
なぜこういう設計なのか(個人的な理解)
ここでは公式の仕様説明と分けて、私なりの理解を整理します。以下は、SOQLが JOIN ... ON を持たない設計理由として公式に確認したものではありません。
- Salesforceはマルチテナント環境で、利用者が直接テーブル構造に触れない(オブジェクトという抽象を通して扱う)
- オブジェクト間の関係は メタデータ(参照関係/主従関係の項目) として管理され、画面やAPI、レポートなどでも使われる
- クエリも「定義済みの関係をたどる」と考えると、Salesforceのデータモデルに沿って理解しやすい
と捉えると納得しやすそうです。
2. 子 → 親:ドット表記でたどる
子オブジェクトから親オブジェクトの項目を取りたい場合は、リレーション名を . でつなぎます。
SELECT LastName, Account.Name, Account.Owner.Name
FROM Contact
SQLでいえば contact → account → user と2段階の結合を書くような参照を、ドット表記で表現できます。ただし、親がない場合の扱いは内部結合とは異なります。
つまずきポイント
-
項目名ではなくリレーション名を書く
- 標準項目:参照項目
AccountIdに対して、リレーション名はAccount - カスタム項目:
Invoice__cに対して、リレーション名はInvoice__r(__c→__r)
- 標準項目:参照項目
- 子→親は最大5階層まで たどれます
- 上のように親の存在を条件にしないクエリでは、参照項目が空のレコードも結果に残り、親側の項目は
nullになります(SQLの左外部結合に近い挙動)。WHERE Account.Name = 'Acme'のような条件を加えた場合は、その条件を満たすレコードだけが返ります - 1クエリで指定できる子→親リレーションの数にも上限があります(公式ドキュメントでは55)
ポリモーフィックな参照項目(ToDoの WhatId / WhoId など)は、参照先の型によって取りたい項目が変わるため、TYPEOF 句を使う方法も用意されています。
3. 親 → 子:サブクエリでたどる
逆に、親から子の一覧を取りたい場合は、SELECT句の中にサブクエリ を書きます。
SELECT Name,
(SELECT LastName FROM Contacts WHERE Email != null)
FROM Account
ここで一番間違えやすいのが、サブクエリの FROM に書くのが オブジェクト名(Contact)ではなく子リレーション名(Contacts) という点です。
- 標準オブジェクト:
Contacts,Opportunities,Casesなど(多くは複数形) - カスタムリレーション:
Invoices__rのように__r付き
子リレーション名はオブジェクトマネージャーの参照項目定義や、describe の結果で確認できます。
※図3の「複数形」は標準リレーションの名前の傾向を表しています。オブジェクト名を複数形にすれば必ず使えるわけではありません。カスタムリレーションでは、設定された子リレーション名に __r を付けて使います。
返り方は「左外部結合」に近い
このサブクエリは、子が1件もない親も結果に残ります。また、子側の WHERE Email != null に合う子がいない場合も、親は結果に残ります。SQLの LEFT JOIN に近いイメージです。
「子が存在する親だけ」に絞りたい場合は、次の semi-join(IN サブクエリ)を使います。
-- 受注済み商談を持つ取引先だけを取得(semi-join)
SELECT Id, Name
FROM Account
WHERE Id IN (SELECT AccountId FROM Opportunity WHERE StageName = 'Closed Won')
-- 商談を1件も持たない取引先(anti-join)
SELECT Id, Name
FROM Account
WHERE Id NOT IN (SELECT AccountId FROM Opportunity)
👉 「親→子のサブクエリ(SELECT句)」と「semi-join / anti-join(WHERE句)」は役割が違う、と分けて覚えると混乱しにくいです。
なお、親→子のサブクエリは1クエリあたりの数(公式ドキュメントでは20)や、ネストの深さにも上限があります。親を第1階層として最大5階層(親から子方向に4段階)まで取得できますが、対応バージョンは経路によって異なります。REST・SOAP APIでは API 58.0以降、Apex では API 61.0(Summer '24)以降が対象です。ただし、Bulk API / Bulk API 2.0、外部オブジェクト、Big Objectでは、この5階層取得は利用できません。
4. SQL → SOQL 読み替えチートシート
SQLの感覚で書きたくなるものを、SOQLでどう表現するかを表にしました。
| やりたいこと | SQL | SOQL |
|---|---|---|
| 親の条件で子を絞る |
INNER JOIN ... ON + WHERE
|
WHERE Account.Name = 'Acme' |
| 左外部結合(親→子) | LEFT JOIN ... ON |
(SELECT ... FROM Contacts) |
| 存在するものだけ(semi-join) |
WHERE EXISTS / IN
|
WHERE Id IN (SELECT AccountId ...) |
| 存在しないもの(anti-join) | WHERE NOT EXISTS |
WHERE Id NOT IN (SELECT ...) |
| 全項目取得 | SELECT * |
FIELDS(ALL) など(制約あり) |
| 任意の列同士の結合 | JOIN ... ON a.x = b.y |
不可(リレーション経由のみ) |
| 更新・削除 |
UPDATE / DELETE
|
SOQLでは不可(Apex DML / API) |
※この表は「やりたいこと」に応じた読み替えの目安です。たとえば親の条件で子レコードを絞ることはできますが、SQLのINNER JOINとSOQLのリレーションクエリは、取得結果の形や表現できる操作が完全に同じではありません。
JOIN以外にも違うところ
JOIN以外にも、SQL経験者が「おや?」となる違いがいくつかあります。
-
SELECT *がない- 代わりに
FIELDS(ALL)/FIELDS(STANDARD)/FIELDS(CUSTOM)がある -
FIELDS(STANDARD)はApexでも利用できます。一方、FIELDS(ALL)とFIELDS(CUSTOM)はApexのインラインSOQL・動的SOQLやBulk API 2.0では利用できません。REST/SOAP APIなどで使う場合も、LIMIT 200以下を指定するなど、取得件数を限定する必要があります。基本は必要な項目を列挙するのが無難です
- 代わりに
-
読み取り専用
- SOQLは検索専用で、作成・更新・削除は Apex の DML(
insert/update/delete)や REST/SOAP API で行う
- SOQLは検索専用で、作成・更新・削除は Apex の DML(
-
任意の結合ができない
- 参照関係のない2オブジェクトを、メールアドレスなどの値で突き合わせることはできない。必要ならクエリを分けてApex側で突き合わせるか、リレーションを設計する
-
日付リテラルが便利
-
WHERE CreatedDate = LAST_N_DAYS:30やTHIS_MONTHなど、SOQLの日付リテラルで期間を表せる
-
-
ガバナ制限がある
- Apexの同期処理では1トランザクションあたりSOQL発行は100回まで、取得行数は合計50,000行までなど。ループの中でSOQLを投げる書き方(いわゆる「SOQL in loop」)は避ける
-
全文検索は別言語
- 複数オブジェクトをまたいだキーワード検索は SOSL(Salesforce Object Search Language)の担当
5. 試して気づいたこと:「JOINしたい」と思ったらリレーションを確認する
SOQLで「この2つをJOINしたいのに書けない」と感じたときは、次の順で考えると整理しやすいと感じました。
-
そもそもリレーション(参照関係/主従関係)は定義されているか?
- 定義されていれば、ドット表記かサブクエリでたどれる
-
取りたいのは「親の情報」か「子の一覧」か?
- 親の情報 → 子起点でドット表記
- 子の一覧 → 親起点でサブクエリ
- 絞り込みたいだけなら semi-join で足りないか?
- どうしてもリレーションがないなら、クエリを分けて Apex で Map を使って突き合わせる
// リレーションがない2オブジェクトを Apex 側で突き合わせる例
Map<String, Lead> leadsByEmail = new Map<String, Lead>();
for (Lead l : [SELECT Id, Email FROM Lead WHERE Email != null]) {
leadsByEmail.put(l.Email.toLowerCase(), l);
}
for (Contact c : [SELECT Id, Email FROM Contact WHERE Email IN :leadsByEmail.keySet()]) {
Lead matched = leadsByEmail.get(c.Email.toLowerCase());
// ...
}
※これは説明用の簡略例です。この例で使うEmail項目のSOQLの = / IN 比較は大文字・小文字を区別しません。一方、Apexの通常の Map<String, ...> のキーは区別するため、toLowerCase() で小文字にそろえています。SOQLの文字列比較には例外があり、大文字・小文字を区別する設定の一意項目では区別されます。
同じメールアドレスのLeadが複数存在する場合、Map<String, Lead> には1件しか保持されません。複数件を扱う場合は Map<String, List<Lead>> などを検討します。
また、この例はEmailのあるLeadを全件取得します。実運用では対象を絞り、LeadとContactの取得行数の合計や、Mapに保持するデータのヒープ使用量を考慮してください。SOQL forループを使っていても、1トランザクションあたりの取得行数の上限はなくなりません。
👉 「JOINが書けない」場合は、まず データモデルに適切なリレーションがあるか を確認すると整理しやすそうです。リレーションがないこと自体が、必ずしも設計ミスというわけではありません。
まとめ
- SQLは「表を
ON句でつなぐ」言語、SOQLは「定義済みのリレーションをたどる」言語 - 子→親は ドット表記(
Account.Name/Invoice__r.Name) - 親→子は 子リレーション名を使ったサブクエリ(
FROM Contacts/FROM Invoices__r) - 存在チェックは semi-join / anti-join(
WHERE Id IN (SELECT ...)) -
SELECT *・任意結合・更新系はなく、代わりにFIELDS()・Apex側の突き合わせ・DML を使う
SOQLは、任意の結合条件を書くのではなく、スキーマに定義されたリレーションをたどる言語 と捉えると理解しやすそうです。
参考(公式情報)
- SOQL and SOSL Reference(Salesforce Developers)
- Relationship Queries
- Relationship Query Limits
- Comparison Operators(文字列比較・semi-join / anti-join)
- Five-Level Parent-to-Child Relationship SOQL Queries in Apex(Summer '24 リリースノート)
- FIELDS()
- Execution Governors and Limits(Apex Developer Guide)
- Apex Developer Guide(PDF:Mapのキー・SOQL forループ・ガバナ制限)



