生成AIにSQLを書かせるときは、「CREATE TABLE 文・列の意味の表・サンプル行・結合キー」の4点セットでテーブル定義を渡し、DBの種類と版、ほしい結果の形を添えるのが基本です。 列名を文章で説明するだけでは、型・NULL の可否・値の種類・テーブル同士のつながりが伝わらず、AI は足りない部分を推測で埋めることになります。

この記事では、4点セットのテンプレートと、定義を取り出すコマンドを PostgreSQL・MySQL・SQLite の3種類ぶん載せました。用途別(新規作成・既存 SQL の修正・遅い SQL の見直し・エラー文からの修正)のプロンプトと方言の指定文を一覧にし、後半では受け取った SQL を架空データ入りの SQLite で動かし、件数と集計値で確かめる手順を、コマンドと出力つきで示します。

確認環境:Ubuntu 24.04、PostgreSQL 16.15、MySQL 8.0.46、SQLite 3.45.1。掲載したコマンドと SQL は、この環境で実行した出力を添えています。プロンプトは特定の AI 製品に依存しない書き方で、AI の出力例は載せていません。確かめる対象の SQL は、この記事で用意した例です。

SQLを書かせるプロンプトの早見表

渡すもの 中身 取り出し方 省くとどうなるか
DBの種類と版 例:PostgreSQL 16、MySQL 8.0、SQLite 3.45 SELECT version(); など 別のDBの関数(日付の整形など)が混ざる
CREATE TABLE 文 列名・型・NULL の可否・制約・索引 pg_dump --schema-only、SHOW CREATE TABLE、.schema 存在しない列名や、型に合わない比較が出る
列の意味の表 列ごとの意味、取りうる値、単位 自分で書く(設計書から写す) status の値や「取り消しを数えるか」を推測される
サンプル行 1テーブルにつき3〜5行(架空の値) LIMIT 3 の結果を架空の値に置き換える 日時の書式や文字の表記ゆれを取り違える
結合キー どの列とどの列でつながるか、1対多の向き 外部キーの定義、または自分で1行ずつ書く 結合条件の取り違えや、行の重複が起きる
ほしい結果 列の並び、並び順、0件の扱い、期間の境目 自分で書く 0件の行が消える、月末の扱いがずれる
出力形式 SQL を1つ、前提にした解釈を箇条書き、確かめ方 自分で書く 説明が長くなり、SQL を探しにくい

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「生成AIにSQLを書かせるプロンプト|テーブル定義の渡し方とテンプレート」

テーブル定義は4点セットで渡す

題材は、架空の「社内の備品予約システム」です。社員(members)、備品(items)、予約(reservations)の3テーブルがあります。

1. CREATE TABLE 文

型・制約・索引は、文章で書き直すより CREATE TABLE 文をそのまま貼るほうが正確で、手間もかかりません。

CREATE TABLE members (
  id         integer PRIMARY KEY,
  name       varchar(50) NOT NULL,
  dept       varchar(30) NOT NULL,
  joined_on  date NOT NULL
);
CREATE TABLE items (
  id        integer PRIMARY KEY,
  name      varchar(50) NOT NULL,
  category  varchar(20) NOT NULL,
  status    varchar(10) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'retired'))
);
CREATE TABLE reservations (
  id         integer PRIMARY KEY,
  member_id  integer NOT NULL REFERENCES members (id),
  item_id    integer NOT NULL REFERENCES items (id),
  start_at   timestamp NOT NULL,
  end_at     timestamp NOT NULL,
  status     varchar(10) NOT NULL CHECK (status IN ('booked', 'returned', 'canceled')),
  CHECK (end_at > start_at)
);
CREATE INDEX idx_reservations_item ON reservations (item_id);

2. 列の意味の表

CREATE TABLE 文からは読み取れない「値の意味」と「数え方の決まり」を表にします。AI が推測しがちな列だけで十分です。

| テーブル | 列 | 意味 | 取りうる値・注意 |
|---|---|---|---|
| items | status | 備品の状態 | active=貸出対象 / retired=廃棄済み(集計から外す) |
| reservations | start_at | 利用開始日時 | 日本時間。月の集計はこの列で判定する |
| reservations | end_at | 利用終了日時 | 日をまたぐことがある |
| reservations | status | 予約の状態 | booked=予約中 / returned=返却済み / canceled=取り消し(利用回数に数えない) |

3. サンプル行

日時の書式や値の表記を伝えるために、数行だけ添えます。本物のデータは貼らず、架空の値に置き換えます。

reservations(3行)
 id | member_id | item_id |      start_at       |       end_at        |  status
----+-----------+---------+---------------------+---------------------+----------
  1 |         1 |       1 | 2026-09-01 10:00:00 | 2026-09-01 12:00:00 | returned
  2 |         1 |       3 | 2026-09-03 09:00:00 | 2026-09-03 18:00:00 | returned
  3 |         2 |       1 | 2026-09-10 13:00:00 | 2026-09-10 15:00:00 | returned

4. 結合キー

外部キーが定義に入っていても、つながりと向きを1行ずつ書くと確実です。

- reservations.member_id = members.id(社員1人に予約が0件以上)
- reservations.item_id = items.id(備品1つに予約が0件以上)

結合キーを別に書く理由は、定義の出力に外部キーが現れないことがあるためです。たとえば上の CREATE TABLE 文を MySQL 8.0.46 でそのまま実行して SHOW CREATE TABLE を見ると、列の横に書いた REFERENCES は外部キーとして残りませんでした。

Create Table: CREATE TABLE `reservations` (
  `id` int NOT NULL,
  `member_id` int NOT NULL,
  `item_id` int NOT NULL,
  `start_at` datetime NOT NULL,
  `end_at` datetime NOT NULL,
  `status` varchar(10) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_reservations_item` (`item_id`),
  CONSTRAINT `reservations_chk_1` CHECK ((`status` in (_utf8mb4'booked',_utf8mb4'returned',_utf8mb4'canceled'))),
  CONSTRAINT `reservations_chk_2` CHECK ((`end_at` > `start_at`))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci

FOREIGN KEY (member_id) REFERENCES members (id) の形で追加すると、次のように出力に現れます。

  CONSTRAINT `fk_res_item` FOREIGN KEY (`item_id`) REFERENCES `items` (`id`),
  CONSTRAINT `fk_res_member` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`),

外部キーを張っていないテーブルもよくあるので、結合キーは定義とは別に書くと決めておくと漏れません。

テーブル定義を取り出すコマンド(DB別)

DB CREATE TABLE 文を取り出す 列の一覧を見る サンプル行を取る
PostgreSQL pg_dump --schema-only --no-owner --no-privileges -t テーブル名 DB名 psql で \d テーブル名 SELECT * FROM テーブル名 ORDER BY id LIMIT 3;
MySQL SHOW CREATE TABLE テーブル名\G、または mysqldump --no-data DB名 テーブル名 SHOW CREATE TABLE の出力に含まれる 同上
SQLite sqlite3 ファイル名 ".schema テーブル名" .schema の出力に含まれる 同上(.mode insert テーブル名 で INSERT 文の形にもできる)

PostgreSQL:pg_dump --schema-only

pg_dump --schema-only --no-owner --no-privileges -t reservations t362
CREATE TABLE public.reservations (
    id integer NOT NULL,
    member_id integer NOT NULL,
    item_id integer NOT NULL,
    start_at timestamp without time zone NOT NULL,
    end_at timestamp without time zone NOT NULL,
    status character varying(10) NOT NULL,
    CONSTRAINT reservations_check CHECK ((end_at > start_at)),
    CONSTRAINT reservations_status_check CHECK (((status)::text = ANY ((ARRAY['booked'::character varying, 'returned'::character varying, 'canceled'::character varying])::text[])))
);
…(省略)…
ALTER TABLE ONLY public.reservations
    ADD CONSTRAINT reservations_pkey PRIMARY KEY (id);
CREATE INDEX idx_reservations_item ON public.reservations USING btree (item_id);
ALTER TABLE ONLY public.reservations
    ADD CONSTRAINT reservations_item_id_fkey FOREIGN KEY (item_id) REFERENCES public.items(id);
ALTER TABLE ONLY public.reservations
    ADD CONSTRAINT reservations_member_id_fkey FOREIGN KEY (member_id) REFERENCES public.members(id);

出力の先頭には SET 文やコメント行が並びます(上では省略)。プロンプトに貼るのは CREATE TABLE から下、主キー・索引・外部キーの行までで足ります。PostgreSQL 16 の公式ドキュメントでは、各オプションは次のように説明されています。

オプション 公式ドキュメントの説明(要約)
-s / --schema-only オブジェクトの定義だけを出力し、データは出力しない
-t パターン / --table 名前がパターンに合うテーブルだけを出力する。複数書ける
-O / --no-owner 所有者を設定するコマンドを出力しない
-x / --no-privileges アクセス権限(GRANT/REVOKE)を出力しない

手早く見たいときは、psql の \d でも列・索引・制約・外部キーが1画面にまとまります。

                       Table "public.reservations"
  Column   |            Type             | Collation | Nullable | Default 
-----------+-----------------------------+-----------+----------+---------
 id        | integer                     |           | not null | 
 member_id | integer                     |           | not null | 
 item_id   | integer                     |           | not null | 
 start_at  | timestamp without time zone |           | not null | 
 end_at    | timestamp without time zone |           | not null | 
 status    | character varying(10)       |           | not null | 
Indexes:
    "reservations_pkey" PRIMARY KEY, btree (id)
    "idx_reservations_item" btree (item_id)
Check constraints:
    "reservations_check" CHECK (end_at > start_at)
    "reservations_status_check" CHECK (status::text = ANY (ARRAY['booked'::character varying, 'returned'::character varying, 'canceled'::character varying]::text[]))
Foreign-key constraints:
    "reservations_item_id_fkey" FOREIGN KEY (item_id) REFERENCES items(id)
    "reservations_member_id_fkey" FOREIGN KEY (member_id) REFERENCES members(id)

MySQL:SHOW CREATE TABLE

SHOW CREATE TABLE reservations\G

出力は前の節に載せたとおりです。ファイルに保存したいときは mysqldump --no-data --skip-comments DB名 テーブル名 でも同じ CREATE TABLE 文が得られました(先頭に DROP TABLE IF EXISTS の行が付くので、貼る前に外します)。

SQLite:.schema

sqlite3 check.db ".schema reservations"
CREATE TABLE reservations (
  id         integer PRIMARY KEY,
  member_id  integer NOT NULL REFERENCES members (id),
  item_id    integer NOT NULL REFERENCES items (id),
  start_at   TEXT NOT NULL,
  end_at     TEXT NOT NULL,
  status     varchar(10) NOT NULL CHECK (status IN ('booked', 'returned', 'canceled')),
  CHECK (end_at > start_at)
);
CREATE INDEX idx_reservations_item ON reservations (item_id);

SQLite は、作成時に書いた CREATE 文をそのまま返します。サンプル行を INSERT 文の形で取りたいときは .mode insert が使えます。

sqlite3 check.db ".mode insert reservations" "SELECT * FROM reservations ORDER BY id LIMIT 3;"
INSERT INTO reservations VALUES(1,1,1,'2026-09-01 10:00:00','2026-09-01 12:00:00','returned');
INSERT INTO reservations VALUES(2,1,3,'2026-09-03 09:00:00','2026-09-03 18:00:00','returned');
INSERT INTO reservations VALUES(3,2,1,'2026-09-10 13:00:00','2026-09-10 15:00:00','returned');

基本テンプレート(4点セット入り)

{ } の部分を書き換えて使います。タグで区切っておくと、どこが定義でどこが依頼かが AI にも人にも見分けやすくなります。

あなたは {PostgreSQL 16} に詳しいデータベースエンジニアです。
下の情報だけをもとに、依頼に合う SQL を1つ書いてください。

<database>
種類と版: {PostgreSQL 16.15}
タイムゾーン: {日時の列は日本時間で保存}
</database>

<ddl>
{CREATE TABLE 文と CREATE INDEX 文を貼る}
</ddl>

<columns>
{列の意味の表を貼る}
</columns>

<sample_rows>
{1テーブルにつき3〜5行。架空の値}
</sample_rows>

<join_keys>
{reservations.member_id = members.id(社員1人に予約が0件以上)}
{reservations.item_id = items.id(備品1つに予約が0件以上)}
</join_keys>

<request>
{2026年9月の、備品ごとの利用回数を出したい。}
- 月の判定は {start_at} で行う。{9月1日0時以上、10月1日0時より前}
- {取り消し(canceled)は数えない}
- {廃棄済み(retired)の備品は出さない。利用が0回の備品は 0 として出す}
- 出力する列: {備品名, 利用回数}。並び順: {利用回数の多い順、同数は備品名の昇順}
</request>

<rules>
- <ddl> にある表と列だけを使う。足りない情報があれば、SQL を書く前に質問する。
- {PostgreSQL 16} で動く構文と関数だけを使う。
- SELECT 文だけを書く(更新・削除・定義変更は書かない)。
- 予約語は大文字、1行に1列、テーブルには短い別名を付ける。
</rules>

<output_format>
1. SQL(コードブロック1つ)
2. 前提にした解釈(箇条書き。期間の境目、NULL と 0件の扱い、重複の扱い)
3. 結果を確かめるための SQL(件数の合計を出すもの)を1つ
</output_format>

依頼文で特に効くのは、期間の境目・除外する値・0件の扱い・並び順の4つです。ここが空いていると、AI は一般的な解釈で埋めます。後半の検証例では、この4つのうち2つが抜けた SQL がどんな結果になるかを確かめます。

用途別プロンプト

基本テンプレートの <request> より下を差し替えます。<database>〜<join_keys> は共通です。

用途 追加で渡すもの 頼み方の要点
新規作成 ほしい結果の例(2〜3行) 期間・除外・0件・並び順を書く
既存 SQL の修正 いまの SQL、変えたい点、変えたくない点 変更箇所だけを変え、理由を添えさせる
遅い SQL の見直し SQL、実行計画、行数の規模、索引 結果を変えない書き換えと、索引の案を分けて出させる
エラー文からの修正 SQL、エラー文の全文、DBの種類と版 原因を1文で述べさせてから直させる

新規作成

基本テンプレートをそのまま使います。結果の形を誤解なく伝えたいときは、次を足します。

<expected_shape>
出力の形の例(値は仮のもの):
name            | cnt
プロジェクターA |   3
プロジェクターB |   0
</expected_shape>

既存 SQL の修正

<current_sql>
{いま動いている SQL}
</current_sql>

<request>
上の SQL を次のように変えてください。
- 変えたい点: {利用が0回の備品も 0 として出す}
- 変えたくない点: {列の名前と並び順、期間の条件}
</request>

<output_format>
1. 修正後の SQL(コードブロック1つ)
2. 変えた箇所の一覧(表:変更前/変更後/理由)
3. 変更前と変更後で結果が変わる行の条件
</output_format>

遅い SQL の見直し

<current_sql>
{遅い SQL}
</current_sql>

<explain>
{EXPLAIN の出力を全文貼る}
</explain>

<table_size>
{reservations: 約300万行 / items: 約2,000行}
</table_size>

<request>
この SQL が遅い理由を実行計画から説明し、改善案を出してください。
</request>

<rules>
- 結果(行と値)を変えない。
- 「SQL の書き換え」と「索引の追加」は分けて提案する。
- 索引の追加は、更新時の負荷が増える点にも触れる。
</rules>

<output_format>
1. 遅い理由(実行計画のどの行から読み取ったかを示す)
2. 書き換えた SQL
3. 索引の案(CREATE INDEX 文)
4. 改善を確かめる手順(実行計画のどこが変わればよいか)
</output_format>

AI の案は、実行計画が変わったかどうかで確かめられます。たとえば SQLite で start_at に索引を張り、月の判定を2通りの書き方で比べると、次のようになりました。

EXPLAIN QUERY PLAN
SELECT COUNT(*) FROM reservations WHERE strftime('%Y-%m', start_at) = '2026-09';
QUERY PLAN
`--SCAN reservations USING COVERING INDEX idx_reservations_start
EXPLAIN QUERY PLAN
SELECT COUNT(*) FROM reservations WHERE start_at >= '2026-09-01' AND start_at < '2026-10-01';
QUERY PLAN
`--SEARCH reservations USING COVERING INDEX idx_reservations_start (start_at>? AND start_at<?)

列を関数で包むと索引を端から端まで読む SCAN、範囲で書くと必要な部分だけを読む SEARCH になります。件数はどちらも 6 で同じでした(この件数は取り消しも含めた9月の予約数です)。

エラー文からの修正

<current_sql>
{エラーになった SQL}
</current_sql>

<error>
{エラー文を全文貼る。行番号や ^ の位置も含める}
</error>

<request>
このエラーの原因を1文で説明し、{PostgreSQL 16} で動く形に直してください。
結果の意味は変えないでください。
</request>

エラー文は要約せず全文を貼るのがコツです。たとえば MySQL 向けの DATE_FORMAT を別のDBで実行すると、次のエラーになります。関数名と位置が含まれているので、AI は原因を特定しやすくなります。

PostgreSQL 16.15:

ERROR:  function date_format(timestamp without time zone, unknown) does not exist
LINE 1: SELECT DATE_FORMAT(start_at, '%Y-%m') FROM reservations
               ^
HINT:  No function matches the given name and argument types. You might need to add explicit type casts.

SQLite 3.45.1:

Error: in prepare, no such function: DATE_FORMAT
  SELECT DATE_FORMAT(start_at, '%Y-%m') FROM reservations;
         ^--- error here

逆に、PostgreSQL 向けの to_char や SQLite 向けの strftime を MySQL 8.0.46 で実行したときは、次のとおりでした。

ERROR 1305 (42000) at line 1: FUNCTION t362.to_char does not exist
ERROR 1305 (42000) at line 1: FUNCTION t362.strftime does not exist

方言の指定文(MySQL・PostgreSQL・SQLite)

DBの種類を書かないと、別のDBの関数が混ざることがあります。<rules> に次の1〜2行を入れます。

DB 指定文 月ごとに集計するときの関数(実行して確認)
PostgreSQL 「PostgreSQL 16 で動く構文と関数だけを使う。日付の整形は to_char、文字列の連結は || を使う」 to_char(start_at, 'YYYY-MM')
MySQL 「MySQL 8.0 で動く構文と関数だけを使う。日付の整形は DATE_FORMAT を使い、識別子を囲むときはバッククォートにする」 DATE_FORMAT(start_at, '%Y-%m')
SQLite 「SQLite 3.45 で動く構文と関数だけを使う。日時は TEXT 型(YYYY-MM-DD HH:MM:SS)で保存しているので、整形は strftime を使う」 strftime('%Y-%m', start_at)

3つとも、同じデータに対して同じ結果になりました(取り消しを除いた月別の予約数)。

-- PostgreSQL
SELECT to_char(start_at, 'YYYY-MM') AS ym, COUNT(*) AS cnt
FROM reservations WHERE status <> 'canceled' GROUP BY ym ORDER BY ym;

-- MySQL
SELECT DATE_FORMAT(start_at, '%Y-%m') AS ym, COUNT(*) AS cnt
FROM reservations WHERE status <> 'canceled' GROUP BY ym ORDER BY ym;

-- SQLite
SELECT strftime('%Y-%m', start_at) AS ym, COUNT(*) AS cnt
FROM reservations WHERE status <> 'canceled' GROUP BY ym ORDER BY ym;
   ym    | cnt 
---------+-----
 2026-08 |   1
 2026-09 |   5
 2026-10 |   1

方言の差が出やすいのは、日付の整形と加減算、文字列の連結、識別子の囲み方、行数の制限、連番の書き方です。どの関数を使ってよいかまで指定文に書くと、やり直しが減ります。

受け取ったSQLを架空データ入りのSQLiteで確かめる

AI から受け取った SQL は、いきなり本番の DB で流さず、答えを手で数えられる小さなデータで確かめます。SQLite はファイル1つで動くので、確認用の場所として手軽です。

手順1:架空データを用意する

定義(上の CREATE TABLE 文の timestamp を TEXT に変えたもの)を schema_sqlite.sql、次のデータを data.sql として保存します。境目になる行をわざと入れるのがポイントです。

INSERT INTO members (id, name, dept, joined_on) VALUES
  (1, '青木', '開発部', '2022-04-01'),
  (2, '石田', '開発部', '2023-04-01'),
  (3, '上野', '総務部', '2021-10-01'),
  (4, '江口', '営業部', '2024-04-01');
INSERT INTO items (id, name, category, status) VALUES
  (1, 'プロジェクターA', '映像', 'active'),
  (2, 'プロジェクターB', '映像', 'active'),
  (3, 'ノートPC-01', 'PC', 'active'),
  (4, 'ノートPC-02', 'PC', 'retired'),
  (5, '会議用マイク', '音響', 'active');
INSERT INTO reservations (id, member_id, item_id, start_at, end_at, status) VALUES
  (1, 1, 1, '2026-09-01 10:00:00', '2026-09-01 12:00:00', 'returned'),
  (2, 1, 3, '2026-09-03 09:00:00', '2026-09-03 18:00:00', 'returned'),
  (3, 2, 1, '2026-09-10 13:00:00', '2026-09-10 15:00:00', 'returned'),
  (4, 2, 2, '2026-09-15 10:00:00', '2026-09-15 11:00:00', 'canceled'),
  (5, 3, 5, '2026-09-20 14:00:00', '2026-09-20 16:00:00', 'returned'),
  (6, 3, 1, '2026-09-30 23:00:00', '2026-10-01 01:00:00', 'returned'),
  (7, 1, 5, '2026-10-01 10:00:00', '2026-10-01 11:00:00', 'booked'),
  (8, 4, 3, '2026-08-31 09:00:00', '2026-08-31 17:00:00', 'returned');
行 入れた理由
予約4(canceled) 取り消しが除かれるかを見る
予約6(9月30日 23時開始) 月末の最終日が含まれるかを見る
予約7(10月1日)、予約8(8月31日) 前後の月が混ざらないかを見る
備品2(予約は取り消しの1件だけ) 0回の備品が 0 として出るかを見る
備品4(retired) 廃棄済みが出ないかを見る
sqlite3 check.db < schema_sqlite.sql
sqlite3 check.db < data.sql

手順2:期待する答えを先に手で出す

「2026年9月、取り消しを除く、備品ごとの利用回数」を表から数えます。対象は予約1・2・3・5・6の合計5件です。

備品 該当する予約 期待する回数
プロジェクターA 1、3、6 3
ノートPC-01 2 1
会議用マイク 5 1
プロジェクターB なし 0

合計だけなら、結合のない単純な SQL でも確かめられます。

sqlite3 -header -column check.db "SELECT COUNT(*) AS total FROM reservations WHERE start_at >= '2026-09-01' AND start_at < '2026-10-01' AND status <> 'canceled';"
total
-----
5    

手順3:受け取ったSQLを流して比べる

確かめる対象の例として、期間を BETWEEN で書き、内部結合を使った SQL を用意しました(cand_a.sql)。

SELECT i.name, COUNT(*) AS cnt
FROM reservations r
JOIN items i ON i.id = r.item_id
WHERE r.start_at BETWEEN '2026-09-01' AND '2026-09-30'
  AND r.status <> 'canceled'
GROUP BY i.name
ORDER BY cnt DESC, i.name;
sqlite3 -header -column check.db < cand_a.sql
name      cnt
--------  ---
プロジェクターA  2  
ノートPC-01  1  
会議用マイク    1  

期待と比べると、2か所が合いません。

比べる点 期待 結果 原因
合計 5 4 BETWEEN ... AND '2026-09-30' は 9月30日 0時までなので、23時開始の予約6が外れた
プロジェクターB 0 の行がある 行がない 内部結合では、予約のない備品の行が作られない

エラーにはならず、もっともらしい表が返ってくる点に注目してください。件数と集計値を期待と突き合わせて、初めて差に気づけます。

手順4:差を伝えて直してもらい、もう一度流す

差が見つかったら、次のように事実だけを伝えます。

<current_sql>
{cand_a.sql の中身}
</current_sql>

<check_result>
架空データで確かめた結果、期待と2点が違いました。
- 合計: 期待 5 件、結果 4 件。start_at が 2026-09-30 23:00:00 の行が含まれていない。
- 利用が0回の備品(プロジェクターB)の行が出ていない。期待は cnt = 0 の行。
</check_result>

<request>
期間は「9月1日0時以上、10月1日0時より前」とし、active の備品は0回でも出すように直してください。
</request>

直した SQL(cand_b.sql)は次のとおりです。

SELECT i.name, COUNT(r.id) AS cnt
FROM items i
LEFT JOIN reservations r
       ON r.item_id = i.id
      AND r.start_at >= '2026-09-01'
      AND r.start_at <  '2026-10-01'
      AND r.status <> 'canceled'
WHERE i.status = 'active'
GROUP BY i.id, i.name
ORDER BY cnt DESC, i.name;
name      cnt
--------  ---
プロジェクターA  3  
ノートPC-01  1  
会議用マイク    1  
プロジェクターB  0  

4行すべてが期待と一致し、cnt の合計も 5 になりました。同じ2つの SQL を PostgreSQL 16.15 と MySQL 8.0.46 でも流したところ、件数は同じでした(直す前はプロジェクターAが 2、直した後は 3 で、プロジェクターBの 0 の行が加わる)。同数の行の並び順は、DBの文字の並べ方によって入れ替わることがあります。

本番のDBで流す前の確認表

小さなデータで合っていることを確かめたら、次の表に沿って本番に近い環境へ進みます。

確認すること やり方 実行して確かめた例
SELECT 文だけか 文の先頭と、; で区切られた2つ目以降の文を目で見る ―
表と列が実在するか 定義と見比べる。流せばエラーで分かる no such function などのエラー文
書き込めない接続で流す 読み取り専用ユーザー、読み取り専用トランザクション、読み取り専用で開く 下の3例
実行計画を見る SQL の前に EXPLAIN(SQLite は EXPLAIN QUERY PLAN)を付ける 下の例
少ない行で形を見る 末尾に LIMIT 10 を付けて列と書式を確かめる 下の例
件数を確かめる 結合のない単純な COUNT と合計が合うか 手順2と手順4
境目を確かめる 月末・0件・NULL・除外する値の行で結果を見る 手順3
行の重複がないか 1対多の結合のあとで COUNT(*) と COUNT(DISTINCT 主キー) を比べる ―

同じ表のタブ区切りです。表計算ソフトにそのまま貼れます。

確認すること  やり方 結果(記入欄)
SELECT 文だけか 文の先頭と、; で区切られた2つ目以降の文を目で見る  
表と列が実在するか   定義と見比べる。流せばエラーで分かる  
書き込めない接続で流す 読み取り専用ユーザー、読み取り専用トランザクション、読み取り専用で開く 
実行計画を見る SQL の前に EXPLAIN(SQLite は EXPLAIN QUERY PLAN)を付ける    
少ない行で形を見る   末尾に LIMIT 10 を付けて列と書式を確かめる  
件数を確かめる 結合のない単純な COUNT と合計が合うか  
境目を確かめる 月末・0件・NULL・除外する値の行で結果を見る    
行の重複がないか    1対多の結合のあとで COUNT(*) と COUNT(DISTINCT 主キー) を比べる  

読み取り専用で流す

受け取った SQL に更新や削除の文が混ざっていても、書き込めない接続なら実行されません。

PostgreSQL:SELECT だけを許したロールを作る。

CREATE ROLE report_ro LOGIN PASSWORD '{パスワード}';
GRANT CONNECT ON DATABASE t362 TO report_ro;
GRANT USAGE ON SCHEMA public TO report_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO report_ro;

このロールで SELECT COUNT(*) FROM reservations は 8 を返し、DELETE FROM reservations は次のように止まりました。

ERROR:  permission denied for table reservations

ロールを作れないときは、読み取り専用のトランザクションでも同じ目的を果たせます。

BEGIN READ ONLY;
DELETE FROM reservations;
ROLLBACK;
BEGIN
ERROR:  cannot execute DELETE in a read-only transaction
ROLLBACK

MySQL:SELECT だけを許したユーザーを作る。

CREATE USER 'report_ro'@'localhost' IDENTIFIED BY '{パスワード}';
GRANT SELECT ON t362.* TO 'report_ro'@'localhost';
ERROR 1142 (42000) at line 1: DELETE command denied to user 'report_ro'@'localhost' for table 'reservations'

SQLite:-readonly を付けて開く。

sqlite3 -readonly check.db "DELETE FROM reservations;"
Error: stepping, attempt to write a readonly database (8)

実行計画を見る

直した SQL(cand_b.sql)の前に EXPLAIN を付けた結果です。行数の多いテーブルを端から端まで読んでいないか、索引が使われているかを見ます。

SQLite(EXPLAIN QUERY PLAN):

QUERY PLAN
|--SCAN i
|--SEARCH r USING INDEX idx_reservations_item (item_id=?) LEFT-JOIN
`--USE TEMP B-TREE FOR ORDER BY

PostgreSQL(EXPLAIN。抜粋):

 Sort  (cost=36.86..36.87 rows=2 width=130)
   Sort Key: (count(r.id)) DESC, i.name
   ->  GroupAggregate  (cost=36.82..36.85 rows=2 width=130)
         Group Key: i.id
…(省略)…
               ->  Nested Loop Left Join  (cost=4.18..36.81 rows=2 width=126)
                     ->  Seq Scan on items i  (cost=0.00..14.12 rows=2 width=122)
                           Filter: ((status)::text = 'active'::text)
                     ->  Bitmap Heap Scan on reservations r  (cost=4.18..11.33 rows=1 width=8)
                           Recheck Cond: (item_id = i.id)
…(省略)…
                           ->  Bitmap Index Scan on idx_reservations_item  (cost=0.00..4.18 rows=4 width=0)
                                 Index Cond: (item_id = i.id)

MySQL(EXPLAIN。主な列を抜粋):

+----+-------------+-------+------+-----------------------+------+------+----------+----------------------------------------------+
| id | select_type | table | type | possible_keys         | key  | rows | filtered | Extra                                        |
+----+-------------+-------+------+-----------------------+------+------+----------+----------------------------------------------+
|  1 | SIMPLE      | i     | ALL  | NULL                  | NULL |    5 |    20.00 | Using where; Using temporary; Using filesort |
|  1 | SIMPLE      | r     | ALL  | idx_reservations_item | NULL |    8 |   100.00 | Using where; Using join buffer (hash join)   |
+----+-------------+-------+------+-----------------------+------+------+----------+----------------------------------------------+

8行しかないデータなので、MySQL は索引を使わず全体を読む計画を選んでいます。実行計画は行数によって変わるため、小さなデータでは「構文が通ること」と「結果が合うこと」を確かめ、速さは本番に近い行数の環境で実行計画を取り直して判断します。

LIMIT で形を見る

sqlite3 -header -column check.db "SELECT id, item_id, start_at, status FROM reservations ORDER BY id LIMIT 3;"
id  item_id  start_at             status  
--  -------  -------------------  --------
1   1        2026-09-01 10:00:00  returned
2   3        2026-09-03 09:00:00  returned
3   1        2026-09-10 13:00:00  returned

LIMIT は返す行数を絞るもので、集計や並べ替えを含む SQL では、絞る前の処理は全体に対して行われます。重い SQL の負荷を下げる目的には、期間の条件を狭めるほうが向いています。

プロンプトに貼る前に見直すこと

見直す点 理由 対応
サンプル行に本物の氏名・連絡先・社外秘の値がないか 外部のサービスに送ることになる 架空の値に置き換える。組織の利用ルールに従う
接続先・パスワード・接続文字列が混ざっていないか 定義の出力やスクリプトに含まれることがある 貼る前に削る
テーブルが多すぎないか 関係のない定義は読み違いのもとになる 依頼に関係するテーブルだけに絞る
列の意味の表が最新か 値の種類が増えていることがある SELECT DISTINCT status FROM ... で実際の値を確かめる

よくある質問

Q. テーブル定義は文章で説明するのと、CREATE TABLE 文を貼るのと、どちらがよいですか? A. CREATE TABLE 文を貼るほうが正確です。型・NULL の可否・制約・索引が一度に伝わり、書き写しの誤りも入りません。文章は、値の意味や数え方の決まりなど、定義から読み取れないことを補うために使います。

Q. サンプル行は何行くらい必要ですか? A. 1テーブルにつき3〜5行が目安です。目的は日時の書式や値の表記を伝えることなので、多くは要りません。取り消しや NULL など、扱いに迷う値を含む行を1つ入れると効果的です。

Q. テーブルが数十個あるときはどうしますか? A. 依頼に関係するテーブルだけを選んで渡します。どれが関係するか分からないときは、先にテーブル名と1行の説明の一覧だけを渡し、「必要なテーブルを挙げてください」と頼んでから、選ばれたテーブルの4点セットを渡す2段階にします。

Q. 確かめるための架空データも AI に作らせてよいですか? A. INSERT 文の下書きを頼むのは便利です。ただし、期待する答えは自分で数えます。SQL と期待値の両方を同じ相手に任せると、同じ解釈のずれが両方に入り、突き合わせても差が出ません。

まとめ

  • テーブル定義は「CREATE TABLE 文・列の意味の表・サンプル行・結合キー」の4点セットで渡す
  • 定義は pg_dump --schema-only、SHOW CREATE TABLE、.schema で取り出し、結合キーは定義とは別に書く
  • 依頼文には期間の境目・除外する値・0件の扱い・並び順を書く
  • DBの種類と版、使ってよい関数を指定文に入れると、別のDBの関数が混ざりにくい
  • エラー文と実行計画は要約せず全文を貼る
  • 受け取った SQL は、境目の行を入れた架空データの SQLite で流し、件数と集計値を手で出した期待と比べる
  • 本番に近い環境では、読み取り専用の接続・実行計画・LIMIT の順に確かめる

参考・出典

  • PostgreSQL 16 Documentation「pg_dump」 https://www.postgresql.org/docs/16/app-pgdump.html (2026年10月5日に閲覧。--schema-only、--table、--no-owner、--no-privileges の説明)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「生成AIにSQLを書かせるプロンプト|テーブル定義の渡し方とテンプレート」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?