UPSERT / JSON / 配列 / 全文検索
現代 SQL の便利機能群。INSERT ... ON CONFLICT、JSONB 操作、配列、全文検索、地理空間、pgvector まで。
UPSERT (INSERT ... ON CONFLICT)
「無ければ INSERT、あれば UPDATE」を 1 文で。Postgres の代表的機能。
INSERT INTO users (id, email, name)
VALUES (1, 'alice@ex.com', 'Alice')
ON CONFLICT (id) DO UPDATE
SET email = EXCLUDED.email,
name = EXCLUDED.name;
EXCLUDED の意味
ON CONFLICT で挿入しようとした行を指す擬似テーブル。条件で使い分けられる:
INSERT INTO products (sku, name, stock)
VALUES ('A1', 'Widget', 10)
ON CONFLICT (sku) DO UPDATE
SET stock = products.stock + EXCLUDED.stock;
-- 同じ SKU なら stock を加算
ON CONFLICT DO NOTHING
INSERT INTO logs (event_id, payload)
VALUES (1, '...')
ON CONFLICT (event_id) DO NOTHING;
WHERE 付き
INSERT INTO products (sku, price)
VALUES ('A1', 100)
ON CONFLICT (sku) DO UPDATE
SET price = EXCLUDED.price
WHERE products.price < EXCLUDED.price;
-- 値上げのみ反映
MERGE(SQL 標準)
Postgres 15+ / SQL Server / Oracle で利用可:
MERGE INTO users u
USING (VALUES (1, 'alice@ex.com', 'Alice')) AS s(id, email, name)
ON u.id = s.id
WHEN MATCHED THEN UPDATE SET email = s.email, name = s.name
WHEN NOT MATCHED THEN INSERT (id, email, name) VALUES (s.id, s.email, s.name);
RETURNING でデータを返す
INSERT INTO posts (title) VALUES ('hello')
ON CONFLICT (title) DO UPDATE SET updated_at = NOW()
RETURNING id, (xmax = 0) AS inserted;
-- inserted = true なら新規、false なら更新
JSON / JSONB(Postgres)
- JSON: テキストとして保存、パース毎回
- JSONB: バイナリ、インデックス可、推奨
作成
SELECT '{"a": 1, "b": [2, 3]}'::jsonb;
SELECT json_build_object('id', 1, 'name', 'Alice');
SELECT jsonb_build_array(1, 2, 3);
取り出し
-- -> JSON / JSONB を返す
-- ->> テキストを返す
SELECT meta->'tags' FROM posts; -- jsonb
SELECT meta->>'title' FROM posts; -- text
-- パス
SELECT meta#>'{address,city}' FROM users;
SELECT meta#>>'{address,city}' FROM users;
-- jsonb_path_query (SQL/JSON path)
SELECT jsonb_path_query(meta, '$.tags[*]') FROM posts;
検索
-- 部分一致 (@> / <@)
SELECT * FROM posts WHERE meta @> '{"tags": ["js"]}';
-- キー存在
SELECT * FROM posts WHERE meta ? 'category';
SELECT * FROM posts WHERE meta ?| array['a','b']; -- いずれか
SELECT * FROM posts WHERE meta ?& array['a','b']; -- 全部
更新
-- マージ(||)
UPDATE posts SET meta = meta || '{"views": 100}' WHERE id = 1;
-- パスでセット
UPDATE posts SET meta = jsonb_set(meta, '{address,city}', '"Tokyo"') WHERE id = 1;
-- 削除
UPDATE posts SET meta = meta - 'tags' WHERE id = 1;
UPDATE posts SET meta = meta #- '{address,city}' WHERE id = 1;
JSON インデックス
-- 全機能
CREATE INDEX posts_meta_gin ON posts USING GIN (meta);
-- 包含検索専用(小さい・速い)
CREATE INDEX posts_meta_path ON posts USING GIN (meta jsonb_path_ops);
JSON 集約
SELECT
p.id,
jsonb_agg(c ORDER BY c.created_at) AS comments
FROM posts p
LEFT JOIN comments c ON c.post_id = p.id
GROUP BY p.id;
配列(Postgres)
CREATE TABLE posts (id BIGSERIAL, tags TEXT[]);
INSERT INTO posts (tags) VALUES (ARRAY['js', 'react']);
INSERT INTO posts (tags) VALUES ('{ts,nextjs}');
-- 取得
SELECT tags[1], tags[2:3], array_length(tags, 1) FROM posts;
-- 検索
SELECT * FROM posts WHERE 'js' = ANY (tags);
SELECT * FROM posts WHERE tags @> ARRAY['js', 'react']; -- 全部含む
SELECT * FROM posts WHERE tags && ARRAY['js', 'ts']; -- いずれか
SELECT * FROM posts WHERE array_length(tags, 1) > 2;
-- 展開
SELECT id, unnest(tags) AS tag FROM posts;
全文検索(Postgres tsvector)
基礎
SELECT to_tsvector('simple', 'The quick brown fox');
-- 'brown':3 'fox':4 'quick':2 'the':1
SELECT to_tsquery('simple', 'fox & brown');
-- 'fox' & 'brown'
SELECT to_tsvector('simple', 'The quick brown fox') @@ to_tsquery('simple', 'fox');
-- true
テーブルに組み込む
ALTER TABLE posts
ADD COLUMN fts tsvector
GENERATED ALWAYS AS (
to_tsvector('simple',
coalesce(title, '') || ' ' || coalesce(body, ''))
) STORED;
CREATE INDEX posts_fts_idx ON posts USING GIN (fts);
SELECT * FROM posts
WHERE fts @@ websearch_to_tsquery('simple', 'react performance');
websearch_to_tsquery
Google 検索風の構文を扱える:
react performance— ANDreact OR vue— ORreact -hooks— 除外"exact phrase"— 完全一致フレーズ
ランキング
SELECT
id, title,
ts_rank(fts, websearch_to_tsquery('simple', 'react')) AS rank
FROM posts
WHERE fts @@ websearch_to_tsquery('simple', 'react')
ORDER BY rank DESC
LIMIT 20;
言語別
- 英語:
'english'— ストップワード除外、語幹抽出 - 日本語: 標準では非対応。
pg_bigm/pgroonga/ Elasticsearch / Meilisearch を併用
pg_trgm(部分一致 / あいまい)
CREATE EXTENSION pg_trgm;
CREATE INDEX users_name_trgm ON users USING GIN (name gin_trgm_ops);
-- 部分一致が高速
SELECT * FROM users WHERE name ILIKE '%alice%';
-- 類似度
SELECT * FROM users WHERE name % 'alise'; -- typo にも対応
SELECT name, similarity(name, 'alise') FROM users ORDER BY 2 DESC;
地理空間(PostGIS)
CREATE EXTENSION postgis;
CREATE TABLE places (
id BIGSERIAL,
name TEXT,
geom GEOGRAPHY(POINT, 4326)
);
INSERT INTO places (name, geom)
VALUES ('Tokyo Station', ST_MakePoint(139.766, 35.681));
CREATE INDEX places_geom_idx ON places USING GIST (geom);
-- 1km 以内
SELECT * FROM places
WHERE ST_DWithin(geom, ST_MakePoint(139.7, 35.7)::geography, 1000);
-- 距離順
SELECT name, ST_Distance(geom, ST_MakePoint(139.7, 35.7)::geography) AS m
FROM places
ORDER BY geom <-> ST_MakePoint(139.7, 35.7)::geography
LIMIT 10;
pgvector(ベクトル検索)
CREATE EXTENSION vector;
CREATE TABLE embeddings (
id BIGSERIAL PRIMARY KEY,
content TEXT,
embedding vector(1536)
);
CREATE INDEX embeddings_idx
ON embeddings USING HNSW (embedding vector_cosine_ops);
-- 類似検索
SELECT content, 1 - (embedding <=> $1) AS similarity
FROM embeddings
ORDER BY embedding <=> $1
LIMIT 5;
UUID
SELECT gen_random_uuid(); -- pgcrypto
SELECT uuid_generate_v4(); -- uuid-ossp
-- v7(時系列含む、Postgres 18+)
SELECT uuidv7();
範囲型
CREATE TABLE bookings (
id BIGSERIAL,
room_id BIGINT,
during tstzrange NOT NULL,
EXCLUDE USING GIST (room_id WITH =, during WITH &&)
);
-- 同じ部屋で時間が重なる予約を DB が拒否
generate_series
SELECT day::date FROM generate_series(
'2026-01-01'::date,
'2026-12-31'::date,
'1 day'::interval
) AS day;
LATERAL JOIN
外側の列を参照するサブクエリ。詳細は JOIN。
非正規化のためのマテビュー
CREATE MATERIALIZED VIEW post_summary AS
SELECT
p.id, p.title, p.user_id,
count(c.id) AS comment_count,
max(c.created_at) AS last_comment_at
FROM posts p
LEFT JOIN comments c ON c.post_id = p.id
GROUP BY p.id;
CREATE UNIQUE INDEX post_summary_idx ON post_summary (id);
-- 定期更新
REFRESH MATERIALIZED VIEW CONCURRENTLY post_summary;
EXPLAIN ANALYZE 込みでの実行計画確認
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM posts WHERE meta @> '{"tag":"js"}';
ROW 値の比較
-- (a, b) を辞書順に比較
SELECT * FROM events
WHERE (created_at, id) > ('2026-01-01', 100)
ORDER BY created_at, id
LIMIT 20;
-- カーソル式ページングの安全な書き方
失敗パターン
| 症状 | 対処 |
|---|---|
| UPSERT で UNIQUE 違反 | ON CONFLICT 列が UNIQUE 制約と一致しているか |
| JSON クエリが遅い | GIN インデックス、jsonb_path_ops |
| 日本語全文検索ダメ | pgroonga / pg_bigm / 外部検索 (Meilisearch) |
| ベクトル検索遅い | HNSW / IVFFlat、ef_search 調整 |
| 巨大 JSONB | 列に展開、必要部分だけ JSONB |
Postgres の魅力
UPSERT / JSON / 配列 / 全文検索 / 地理空間 / ベクトル / 時系列、すべて1 つの DBでこなせる。 特殊用途で別 DB を立てる前に、Postgres の拡張で済むか試すのが鉄則。