本文へスキップ
ウェブエンジニア問題集
第5章

SQLiteのSQL方言 — UPSERT・RETURNING・JSON関数・ALTER TABLE

約14分
この章の目次開く

SELECT や JOIN の書き方は、SQLとデータベースの基礎で学んだ内容がそのまま通用します。この章では、SQLiteならではの書き方だけに絞って扱います。

具体的には、重複時の挙動を指定する UPSERT、更新した行をそのまま返す RETURNING、日付時刻型がないSQLiteでの日付の扱い、JSONをそのまま格納して検索するJSON関数、そして制約の多い ALTER TABLE です。

学習者学習者

「方言」って、SQLiteだけ書き方が違うところ、という意味ですか?

先生先生

そう。標準SQLから外れている部分と、標準にはあるけどSQLiteには無い部分、その両方だね。ここを押さえておくと、他のDBから移ってきたSQLがなぜ動かないかがすぐ分かる。

UPSERT — あれば更新、なければ挿入

「すでに同じキーの行があれば更新、なければ挿入」という処理は、ON CONFLICT 句で書けます。

構文: INSERT INTO テーブル (...) VALUES (...) ON CONFLICT(競合する列) DO UPDATE SET ...

部分説明
ON CONFLICT(列)どの制約と衝突したときに動くかを指定する。UNIQUE か PRIMARY KEY が必要
DO UPDATE SET ...衝突したときに実行する更新内容
DO NOTHING衝突したときは何もせず、エラーにもしない
excluded.列名挿入しようとした値を参照する特別なテーブル名
CREATE TABLE page_views (
  path TEXT NOT NULL PRIMARY KEY,
  count INTEGER NOT NULL DEFAULT 0,
  updated_at TEXT NOT NULL DEFAULT (datetime('now'))
) STRICT;
 
INSERT INTO page_views (path, count) VALUES ('/books', 1)
ON CONFLICT(path) DO UPDATE SET
  count = count + 1,
  updated_at = datetime('now');
sql

1回目は挿入され、2回目以降は count が加算されます。

excluded は「挿入しようとしたが弾かれた行」を指す仮想テーブルです。新しく渡した値を更新側で使いたいときに必要になります。
INSERT INTO users (email, name) VALUES ('a@example.com', '田中太郎')
ON CONFLICT(email) DO UPDATE SET
  name = excluded.name;  -- 新しく渡した '田中太郎' で上書き
sql

DO NOTHING は「あれば無視」を実現します。

INSERT INTO tags (name) VALUES ('SQLite')
ON CONFLICT(name) DO NOTHING;
sql

INSERT OR ... という古い書き方

SQLiteには ON CONFLICT 以前からある、独自の短縮構文もあります。

構文挙動
INSERT OR IGNORE制約違反の行を黙って無視する
INSERT OR REPLACE既存の行を削除してから新しい行を挿入する
INSERT OR ABORTエラーにする(既定)
INSERT OR ROLLBACKトランザクション全体を巻き戻す

RETURNING — 変更した行をそのまま受け取る

INSERT / UPDATE / DELETE の末尾に RETURNING を付けると、変更対象の行を結果として受け取れます。SQLite 3.35.0 以降で使えます。

INSERT INTO users (name, email) VALUES ('佐藤', 'sato@example.com')
RETURNING id, created_at;
sql
┌────┬─────────────────────┐
│ id │     created_at      │
├────┼─────────────────────┤
│ 42 │ 2026-08-15 10:30:00 │
└────┴─────────────────────┘

これがないと、挿入したIDを知るために別のクエリを投げる必要がありました。

-- RETURNINGがない場合の従来のやり方
INSERT INTO users (name) VALUES ('佐藤');
SELECT last_insert_rowid();
sql
RETURNING は削除した行の内容を確認するときにも便利です。「何を消したか」をログに残せます。
DELETE FROM sessions WHERE expires_at < datetime('now')
RETURNING id, user_id;
sql
投げたものが返ってくるイメージ
RETURNINGを使うと、書き込んだ結果をそのまま受け取れます

日付と時刻を扱う

第3章で触れた通り、SQLiteに日付時刻型はありません。代わりに、TEXT・INTEGER・REALで表現された値を解釈する関数群が用意されています。

関数戻り値
date(時刻値, 修飾子...)YYYY-MM-DD 形式の文字列
time(時刻値, 修飾子...)HH:MM:SS 形式の文字列
datetime(時刻値, 修飾子...)YYYY-MM-DD HH:MM:SS 形式の文字列
unixepoch(時刻値, 修飾子...)Unix時間(整数)
strftime(書式, 時刻値, 修飾子...)書式に従って整形した文字列

第1引数の時刻値には、ISO 8601形式の文字列、'now'、Unix時間などを渡せます。

SELECT datetime('now');              -- 現在時刻(UTC)
SELECT date('2026-08-15 10:30:00');  -- 2026-08-15
SELECT datetime(1786000000, 'unixepoch');
sql
SELECT datetime('now', 'localtime');  -- 実行環境のタイムゾーンに変換
sql

修飾子は第2引数以降にいくつでも並べられ、左から順に適用されます。

修飾子意味
'+7 days' / '-1 month'加算・減算(years / months / days / hours / minutes / seconds)
'start of month'月初に丸める(year / day も指定可)
'weekday 0'次の日曜日(0=日曜、1=月曜…)まで進める
'unixepoch'第1引数をUnix時間として解釈する
'localtime'UTCからローカル時刻へ変換
'utc'ローカル時刻からUTCへ変換

修飾子を組み合わせると、期間の計算が短く書けます。

-- 今月の初日
SELECT date('now', 'start of month');
 
-- 今月の最終日
SELECT date('now', 'start of month', '+1 month', '-1 day');
 
-- 30日以内に作成されたレコード
SELECT * FROM posts WHERE created_at >= datetime('now', '-30 days');
sql

strftime は自由な書式で整形できます。

SELECT strftime('%Y年%m月%d日', 'now');  -- 2026年08月15日
SELECT strftime('%Y-%m', created_at) AS month, count(*)
FROM posts
GROUP BY month;
sql
書式内容
%Y4桁の年
%m2桁の月
%d2桁の日
%H %M %S時・分・秒
%w曜日(0=日曜)
%j年内の通算日
学習者学習者

文字列で保存していて、範囲検索は速いんですか?

ISO 8601形式は「文字列としての辞書順」と「時系列順」が一致するため、created_at >= '2026-08-01' のような比較がそのまま範囲検索になり、インデックスも効きます。この性質が、TEXT保存を推奨する理由です。

JSONを扱う

SQLiteはJSON関数を標準で内蔵しています(3.38.0以降はビルド時の有効化も不要)。設定値やAPIレスポンスのように、列に分解しづらいデータをそのまま保存して検索できます。

CREATE TABLE events (
  id INTEGER PRIMARY KEY,
  payload TEXT NOT NULL CHECK (json_valid(payload))
) STRICT;
 
INSERT INTO events (payload) VALUES
  ('{"type":"click","user":{"id":1,"name":"田中"},"tags":["a","b"]}');
sql

値を取り出すには ->> 演算子が便利です。

演算子戻り値
->JSON形式のまま返す(文字列なら引用符付き)
->>SQLの値として返す(TEXT / INTEGER / REAL / NULL)
SELECT
  payload -> '$.user.name'  AS as_json,   -- "田中"(引用符付き)
  payload ->> '$.user.name' AS as_text,   -- 田中
  payload ->> '$.user.id'   AS user_id    -- 1(整数)
FROM events;
sql
アプリケーションで使う値を取り出したいときは、ほぼ常に ->> のほうです。-> はJSONを組み立て直すときに使います。

主なJSON関数は次の通りです。

関数戻り値
json_extract(JSON, パス)パスの値。->> とほぼ同じ
json_valid(値)正しいJSONなら 1、そうでなければ 0
json_type(JSON, パス)'object' / 'array' / 'text' / 'integer' など
json_array_length(JSON, パス)配列の要素数
json_object(キー, 値, ...)JSONオブジェクトを組み立てる
json_each(JSON, パス)配列・オブジェクトを行に展開するテーブル関数

配列を検索したい場合は json_each で展開して結合します。

-- tags配列に 'a' を含むイベントを探す
SELECT e.id
FROM events e, json_each(e.payload, '$.tags') t
WHERE t.value = 'a';
sql

JSONを頻繁に検索するなら、生成列にインデックスを張る方法があります。

ALTER TABLE events ADD COLUMN
  user_id INTEGER GENERATED ALWAYS AS (payload ->> '$.user.id') VIRTUAL;
 
CREATE INDEX idx_events_user ON events(user_id);
sql
組み立てるイメージ
JSON列+生成列+インデックスの組み合わせで、柔軟さと検索速度を両立できます

ALTER TABLE でできること・できないこと

SQLiteの ALTER TABLE は、他のデータベースに比べて制限が強い部分です。ただし年々緩和されています。

操作可否対応バージョン
テーブル名の変更できる古くから
列の追加(ADD COLUMN)できる古くから
列名の変更(RENAME COLUMN)できる3.25.0
列の削除(DROP COLUMN)できる3.35.0
NOT NULL / CHECK 制約の追加・削除できる3.53.0
列の型の変更できない—
主キー・外部キー・UNIQUE の変更できない—
列の並び順の変更できない—

DROP COLUMN には条件があり、主キーの一部である列、UNIQUE 制約のある列、インデックスが張られている列などは削除できません。

できない変更はテーブルを作り直す

型の変更や主キーの変更が必要な場合は、新しいテーブルを作ってデータを移します。公式が案内している手順の要点は次の通りです。

PRAGMA foreign_keys = OFF;
 
BEGIN;
 
CREATE TABLE users_new (
  id INTEGER PRIMARY KEY,
  name TEXT NOT NULL,
  age INTEGER
) STRICT;
 
INSERT INTO users_new (id, name, age) SELECT id, name, age FROM users;
 
DROP TABLE users;
ALTER TABLE users_new RENAME TO users;
 
-- インデックス・トリガー・ビューを作り直す
 
PRAGMA foreign_key_check;
 
COMMIT;
 
PRAGMA foreign_keys = ON;
sql
この作り直しの間、インデックスとトリガーは失われます。再作成を忘れると、動くけれど遅いテーブルが出来上がります。
先生先生

PRAGMA foreign_keys の切り替えはトランザクションの外でやるのがポイント。トランザクションの中では設定変更が効かないんだ。

標準SQLとの主な差分

最後に、他のデータベースから移ってきたときに引っかかりやすい点をまとめます。

項目SQLiteでの状況
RIGHT JOIN / FULL OUTER JOIN3.39.0 以降で使える。それ以前は LEFT JOIN で書き換えが必要
ウィンドウ関数3.25.0 以降で使える
CTE(WITH 句)・再帰CTE使える
ILIKEない。LIKE が既定でASCIIのみ大文字小文字を区別しない
RIGHT・LEFT 関数ない。substr() を使う
ストアドプロシージャない
TRUNCATE TABLEない。DELETE FROM テーブル を使う
文字列連結|| を使う(CONCAT() は3.44.0以降)

よくあるハマりどころ

ON CONFLICT の対象列を書き忘れる

ON CONFLICT DO UPDATE では、対象列の指定が必須です。省略できるのは DO NOTHING のときだけです。

-- エラーになる
INSERT INTO users (email, name) VALUES ('a@example.com', '田中')
ON CONFLICT DO UPDATE SET name = excluded.name;
 
-- 対象列を明示する
INSERT INTO users (email, name) VALUES ('a@example.com', '田中')
ON CONFLICT(email) DO UPDATE SET name = excluded.name;
sql

指定した列に UNIQUE または PRIMARY KEY 制約がないと、これもエラーになります。

datetime('now') をDEFAULTに直接書けない

DEFAULT に関数を書く場合は、括弧で囲む必要があります。

-- エラー
created_at TEXT DEFAULT datetime('now')
 
-- 正しい
created_at TEXT DEFAULT (datetime('now'))
sql

タイムゾーンを混在させる

datetime('now') はUTC、datetime('now', 'localtime') はローカル時刻を返します。保存時に両方が混ざると、時系列の比較が壊れます。保存はUTCに統一し、表示のときだけ変換すると決めておいてください。

ちゃんと使うためのポイント

  • 「あれば更新、なければ挿入」は ON CONFLICT(列) DO UPDATE SET、新しい値は excluded.列名 で参照する
  • INSERT OR REPLACE は削除と挿入なので、部分更新には使わない
  • RETURNING で挿入・更新・削除した行をその場で受け取れる
  • 日付はISO 8601のTEXTで保存し、datetime() の修飾子で計算する。保存はUTCに統一
  • JSONは ->> で値を取り出す。頻繁に検索するなら生成列+インデックス
  • ALTER TABLE の制限は年々緩和されている。まず sqlite_version() を確認する
  • 型や主キーの変更はテーブルの作り直し。インデックスとトリガーの再作成を忘れない

次の章では、SQLiteの性能と安定性を大きく左右する WALモードを扱います。「読み取りと書き込みが同時にできる」とはどういうことか、そして SQLITE_BUSY エラーが出る理由と対策を見ていきます。

参考リンク

SQLクイズに挑戦するINSERT・UPDATE・関数まわりの知識を、4択クイズでアウトプットして定着させよう