PostgreSQL「ERROR: invalid byte sequence for encoding」の原因と対処

PostgreSQL「ERROR: invalid byte sequence for encoding」の原因と対処

概要。データがデータベース/クライアントの想定エンコーディングと一致せず、変換できないバイト列(UTF-8 で不正な並び、0x00、サロゲート相当など)が混入すると発生する。発生条件の整理、切り分けの順番、CSV/ETL/アプリ挿入での具体対処、移行時の落とし穴、再発防止までを実務で使える手順とコマンドでまとめる。

発生条件(まず押さえるポイント)

・サーバ/DB が持つ内部エンコーディング(server_encoding / DB encoding)へ変換するときに不正な並びを検出
・典型発生箇所:COPY(\copy 含む)/INSERT/UPDATE/SELECT(文字列リテラル)/関数(convert_from 等)
・代表的な原因:入出力の想定エンコーディング不一致(例:SJIS や CP932 のファイルを UTF8 として取り込み)、UTF-8 に存在しないバイト列、0x00(NUL)混入、BOM/制御文字、SQL_ASCII 由来の生バイト
・エラーメッセージ例:

ERROR:  invalid byte sequence for encoding "UTF8": 0x00
CONTEXT: COPY my_table, line 42

最初にやる確認(サーバ/クライアント/DB のエンコーディング)

-- サーバ/セッション側の状況
SHOW server_encoding;   -- ほとんどの環境は UTF8
SHOW client_encoding;   -- ドライバ/psql が設定
SELECT datname, pg_encoding_to_char(encoding) AS db_enc
FROM pg_database ORDER BY 1;

-- psql ならセッションで明示
\encoding UTF8          -- または SET client_encoding TO 'UTF8';

・server_encoding と DB の encoding は基本一致(DB 作成時に決定)。client_encoding は「クライアントが送る/受け取る文字コード」。ここが実データとズレるとエラーになりやすい。

CSV 取り込み(COPY/\\copy)での典型エラーと対処

-- 1) ファイルの実エンコーディングを特定(file コマンドや nkf -g 等)
file data.csv
nkf -g data.csv

-- 2) 取り込み前に正規化(iconv/nkf で UTF-8 へ)
iconv -f SHIFT_JIS -t UTF-8 data.csv > data.utf8.csv
# NUL や不正バイトを捨てたい場合は //IGNORE を併用(要要件確認)
iconv -f SHIFT_JIS -t UTF-8//IGNORE data.csv > data.utf8.csv

-- 3) 取り込み(BOM/改行も確認)
\encoding UTF8
\copy my_schema.my_table(col1, col2, ...) FROM 'data.utf8.csv' CSV HEADER

・サーバサイド COPY を使う場合は、事前に変換済みファイルを DB サーバ側へ配置するか、PROGRAM + iconv でパイプする。

-- サーバサイドでの変換取り込み(PostgreSQL サーバ OS に iconv がある前提)
COPY my_schema.my_table(col1, col2, …)
FROM PROGRAM 'iconv -f SHIFT_JIS -t UTF-8 /path/data.csv'
WITH (FORMAT csv, HEADER true);

アプリからの挿入時に発生する場合の手当

・接続ドライバが client_encoding を自動交渉できない/誤設定のケースを排除
・接続直後に明示する(アプリの初期化 SQL)

-- 接続直後に 1 回だけ実行(コネクションプールならプール初期化/検証時に)
SET client_encoding TO 'UTF8';

・アプリ側のデータソースが SJIS/ISO-8859-* などなら、アプリ層で UTF-8 に変換してから送る。ログに失敗した文字やバイトオフセットを残す。

0x00(NUL)混入で落ちるパターン

・PostgreSQL の text/varchar は NUL を保持できない。CSV/TSV/JSON に NUL が紛れると 0x00 で停止

# 事前に NUL を除去(不可視文字なのでファイルで処理するのが安全)
tr -d '\000' < data.csv > data.nonul.csv

# iconv で変換と同時に NUL を落とす例(入力が UTF-8 前提)
iconv -f UTF-8 -t UTF-8 -c data.csv > data.clean.csv

・アプリ挿入で NUL が来る場合は、入力段階で reject/置換ポリシーを決める(たとえば U+FFFD に置換)。

SQL_ASCII 由来データの移行で詰まる(検出と救出)

・SQL_ASCII は実質「チェックなし」。生バイトが混在し、UTF8 DB への移行時に invalid byte sequence が頻発
・対処の基本は「元データの実エンコーディングを推定→ダンプ→外部ツールで一括変換→インポート」

# 1) SQL_ASCII DB をダンプ(ロケール情報も記録)
pg_dump -d legacy_db > legacy.sql

# 2) ダンプを iconv で UTF-8 に
iconv -f CP932 -t UTF-8 legacy.sql > legacy.utf8.sql

# 3) 新しい UTF8 DB へリストア
createdb -E UTF8 newdb
psql -d newdb -f legacy.utf8.sql

・テーブル単位で「問題列だけ」再エンコードしたい場合は、CSV に出して列ごとに変換して戻すのが安全。

エラー位置の特定(バイナリサーチ手順)

-- COPY で "line 12345" と出たら、その行周辺を抜き出して目視確認
sed -n '12340,12350p' data.csv | nl -ba

-- 行番号が得られない/巨大ファイルなら半分ずつに分割して再試行
split -l 1000000 data.csv part_
\copy my_table FROM 'part_aa' CSV;  -- 片方が通る/落ちるで範囲が半分になる

・テーブル内の既存データで SELECT が落ちる場合は、主キー順にページングし、範囲を半分にしながら犯人行を絞る(アプリやスクリプトで実施)。

既存の「文字化け」を直すレシピ(再解釈のテクニック)

・UTF-8 のバイト列を誤って LATIN1/CP1252 として取り込んだ/逆に取り込んだケースの修復例(安全な一括適用にはサンプル検証必須)

-- 例: 列 col に「本来は UTF-8 だったのに CP1252 として解釈され文字化け」
-- 手順: まず現状の文字列を "CP1252 としてバイト化" → そのバイト群を "UTF-8 として再解釈"
UPDATE my_schema.t
SET col = convert_from(convert_to(col, 'WIN1252'), 'UTF8')
WHERE id IN (...条件...);

-- 逆方向(UTF-8 として解釈されたが本来は CP1252 だった)
UPDATE my_schema.t
SET col = convert_from(convert_to(col, 'UTF8'), 'WIN1252')
WHERE id IN (...条件...);

・必ず一部のサンプルで ROUND-TRIP の妥当性を確認してから全件に広げる。

関数での安全取り込み(バイト→テキスト変換)

・外部から「生バイト(bytea)」で受け、明示エンコーディングで convert_from する方法

CREATE OR REPLACE FUNCTION decode_text(b bytea, enc name DEFAULT 'UTF8')
RETURNS text LANGUAGE sql IMMUTABLE AS $$
  SELECT convert_from(b, enc)
$$;

-- 使用例:HEX 文字列を bytea 化してから UTF-8 として解釈
SELECT decode_text(decode('E38182', 'hex'), 'UTF8');  -- "あ"

・エラー時にデフォルト置換するバージョン(ポリシーに応じて IGNORE 風の動作を実装)

CREATE OR REPLACE FUNCTION try_decode_text(b bytea, enc name DEFAULT 'UTF8')
RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
  RETURN convert_from(b, enc);
EXCEPTION WHEN character_not_in_repertoire OR invalid_byte_sequence_for_encoding THEN
  -- 必要ならログし、U+FFFD を返す
  RETURN E'\uFFFD';
END$$;

ETL 前処理のテンプレ(Bash + iconv/nkf)

#!/usr/bin/env bash
set -euo pipefail
in="$1"; out="${2:-/tmp/normalized.csv}"
# 想定: 入力は CP932、NUL/不正バイトは除去、LF 統一、BOM 除去
nkf -w -W --oc=UTF-8 --in-place "$in" 2>/dev/null || true
iconv -f CP932 -t UTF-8//IGNORE "$in" | tr -d '\000' | sed '1s/^\xEF\xBB\xBF//' > "$out"
echo "normalized -> $out"

リテラル/関数でコケるときの落とし穴

・SQL リテラル中のエスケープ/文字コードがエディタ依存で壊れている(BOM/改行コード)
・正規表現や LIKE パターンがバイト境界で分断(UTF-8 前提の正規表現に非 UTF-8 バイト)
・json/jsonb への投入前に文字エンコーディングが不正(ETL 側で UTF-8 正規化)

監視と再発防止(運用ポリシー)

・DB は UTF8 を原則にし、アプリ・ETL の入口で UTF-8 に統一
・CSV/ファイル取り込みは「検証→変換→取り込み」をジョブに固定化(iconv/nkf のログ保持)
・psql/ドライバの client_encoding を明示(接続後に SET、プールで固定)
・SQL_ASCII DB は廃止方針。移行時は列ごと/ファイルごとの実エンコーディングを記録してから変換
・不正データ検出用に、ETL でサンプリング検査(iconv -c で除去された件数をメトリクス化)

トラブルシュートの決め手(早見表)

・COPY で「line N」→ 該当行周辺を抽出、ファイルの実エンコを特定、iconv で正規化して再投入
・0x00 エラー → 事前に tr -d ‘\000’ で除去、生成元で NUL 排除
・アプリ挿入で sporadic → 接続直後の SET client_encoding、アプリの文字コード統一
・SQL_ASCII 移行で大量発生 → ダンプを実エンコで iconv、一部列は CSV 化して列単位変換
・「文字化け」修復 → convert_to/convert_from の再解釈を少量で検証→全件反映