markdown
はじめに (対象読者・この記事でわかること)
この記事は、GoでWebアプリケーションを開発していて「SELECTは問題ないのに、特定のテーブルをJOINするとだけエラーが出る」という謎現象に悩んでいるバックエンドエンジニア向けです。
この記事を読むことで、PostgreSQLでJOIN時にのみ発生するエラーの代表的な原因と、Go(GORM)側での回避策・調査手法が身に付きます。環境はPostgreSQL 15、Go 1.22、gorm v1.25を想定しています。
前提知識
- Goの基本的な文法とモジュール管理(go mod)
- GORMを使った簡単なCRUD経験
- PostgreSQLのログ取得方法(log_statement, log_min_duration_statement)
なぜJOINだけが失敗するのか:エラーの分類と原因
JOINでだけ失敗するケースの多くは「SELECT句で指定したカラムが、JOIN先に存在しない/曖昧になった」か「型変換エラーがJOIN時に顕在化した」かのどちらかです。特にGoの構造体とGORMのスキャン処理を組み合わせると、JOIN先のカラムがnilや想定外の型で返ってきた瞬間にsql: Scan errorが出ます。
また、PostgreSQLはSELECT単体ではプランを簡略化するため、JOIN直前まで式を評価しません。そのため「単体では通るがJOINで落ちる」という挙動になります。
実装でハマった事例と完全な対策
ステップ1:エラーの再現とログの取り方
まず、GORMのログレベルとPostgreSQLのサーバログを有効にして、どのSQLで死んでいるかを特定します。
Go// main.go package main import ( "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" "log" "os" ) func main() { dsn := "host=db user=app password=secret dbname=app port=5432 sslmode=disable" db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ Logger: logger.New( log.New(os.Stdout, "\r\n", log.LstdFlags), logger.Config{ LogLevel: logger.Info, // SQL全文出力 }, ), }) if err != nil { log.Fatalln(err) } // ここで問題のJOINを実行 var result []JoinedResult if err := db. Table("orders"). Select("orders.id, orders.total, customers.name"). Joins("JOIN customers ON customers.id = orders.customer_id"). Scan(&result).Error; err != nil { log.Printf("JOIN failed: %v", err) } } type JoinedResult struct { ID uint Total float64 Name string }
PostgreSQL側ではpostgresql.confを一時的に以下のように変更してからサーバを再起動してください。
log_statement = 'all'
log_min_duration_statement = 0
これで標準エラーにSQL全文とエラーの詳細が出るので、「どのカラムで型不一致が起きているか」が特定できます。
ステップ2:GORMの構造体と実際のカラム型を突き合わせる
エラーがsql: Scan error on column index 1, name "total": converting NULL to float64 is not allowedのような場合、Go側がnilを許容していないことが原因です。対応する構造体を書き換えます。
Gotype JoinedResult struct { ID uint Total sql.NullFloat64 `gorm:"column:total"` // NULL許容 Name string }
あるいは、SQL側でNULLを除外/デフォルト値を返すようにしてしまう手もあります。
Godb.Table("orders"). Select("orders.id, COALESCE(orders.total,0) AS total, customers.name"). Joins("JOIN customers ON customers.id = orders.customer_id"). Scan(&result)
ステップ3:カラム名の曖昧性を排除する
ERROR: column reference "created_at" is ambiguousが出る場合、両テーブルに同名列があるためです。SELECT句でエイリアスを付けるか、カラムリストを明示します。
Godb.Table("orders"). Select(`orders.id, orders.total, customers.name, orders.created_at AS order_created, customers.created_at AS customer_created`). Joins("JOIN customers ON customers.id = orders.customer_id"). Scan(&result)
ステップ4:PgHero/EXPLAIN ANALYZEでボトルネックを調べる
単に型不一致ではなく「JOIN先のテーブルが巨大でタイムアウトする」場合もあります。PgHeroやEXPLAIN ANALYZEでプランを見て、インデックスが効っていない場合は以下のように追加します。
SqlCREATE INDEX CONCURRENTLY idx_orders_customer_id ON orders(customer_id);
Go側でタイムアウト値を上げるだけでは根本解決にならないため、必ずインデックス追加やクエリチューニングを行いましょう。
ハマったポイント:GORMのPreloadとJOINの違い
GORM初学者が陥る落とし穴の一つが「Preloadは外部キーを自動でJOINしてくれるから楽!」と安易に使ってN+1を量産してしまうことです。Preloadは複数クエリ発行なので、集約関数を使えません。JOINで書き直すと集約は可能ですが、GORMのデフォルトスキャン構造体と相性が悪く、NULL・型変換エラーが出やすくなります。結果として「SELECTは通るのにJOINだけ失敗する」現象が目立つというわけです。
解決策:スキャン用の専用構造体を用意する
最もトラブルが少ない構成は、「テーブルモデル」と「JOIN結果を受ける専用DTO」を分離することです。
Go// テーブルモデル(GORM自動マイグレーション用) type Order struct { ID uint Total *float64 // NULL許容 CustomerID uint CreatedAt time.Time } type Customer struct { ID uint Name string } // JOIN結果受け専用(マイグレーション不要) type OrderWithCustomer struct { OrderID uint Total sql.NullFloat64 CusName string } var dto []OrderWithCustomer db.Table("orders"). Select("orders.id AS order_id, "+ "COALESCE(orders.total,0) AS total, "+ "customers.name AS cus_name"). Joins("JOIN customers ON customers.id = orders.customer_id"). Scan(&dto)
これでGORMのモデル制約(構造体フィールド=カラム名)に縛られず、JOINのSELECTリストを自在に組めます。型変換エラーもsql.Null*で防げます。
まとめ
本記事では、Go+PostgreSQLで「SELECT単体は成功するのにJOINだけ失敗する」現象の調べ方と、GORMで安全にJOINするための実装パターンを紹介しました。
- PostgreSQLではJOIN時に初めて式が評価され、型不一致・カラム曖昧性が顕在化する
- GORMの構造体は
sql.Null*でNULLを許容するか、SQL側でCOALESCEでデフォルト値を返す - ログを有効にして「どのカラムで死んでいるか」を特定することが最優先
- テーブルモデルとJOIN専用DTOを分離すると、型変換エラーを回避できる
この知識があれば、JOINで突発するsql: Scan errorにも慌てなくなります。次回は「GORMのカスタムデータ型とValuer/Scannerインタフェース」を活用して、より高度な型マッピングを実装する方法を解説します。
参考資料
