Go sqlxで書く型安全な生SQL—database/sqlからの乗り換え手順と落とし穴

Go sqlxで書く型安全な生SQL—database/sqlからの乗り換え手順と落とし穴 | mohablog

Goでデータベースを触るときに、database/sql だけで頑張っていると rows.Scan(&u.ID, &u.Name, &u.Email, ...) のようなカラム数ぶんの引数列が増殖します。20カラムあるテーブルだと、Scan の引数だけで縦に20行使うコードを書くことになり、レビューする側もしんどい。先日、社内ツールのリポジトリでこの状態を解消しようとして、GORMではなく jmoiron/sqlx(執筆時点で v1.4 系)に寄せたら、コード行数が3割減り、N+1の温床も減りました。

とはいえ sqlx は database/sql の薄いラッパーなので、ORMほどお膳立てしてくれるわけではありません。Get/SelectQueryx の使い分けや、sqlx.In での IN 句展開、sqlx.Tx 内で1接続を共有することによる制約など、知っておかないとハマる箇所がいくつもあります。この記事では、database/sql からの移行を題材に、sqlx の主要 API と典型的な落とし穴を整理しました。動作確認は Go 1.23、PostgreSQL 16、github.com/jmoiron/sqlx v1.4.0、ドライバは github.com/jackc/pgx/v5/stdlib で行っています。

目次

database/sqlで書いたCRUDが膨らむ理由

まずは sqlx を導入する前のコードを見ておきます。これは標準の database/sql でユーザー一覧を取得する典型的な書き方です。

NGに近い書き方—Scanの引数列が伸びる

type User struct {
    ID        int64
    Name      string
    Email     string
    CreatedAt time.Time
}

func listUsers(ctx context.Context, db *sql.DB) ([]User, error) {
    rows, err := db.QueryContext(ctx, `SELECT id, name, email, created_at FROM users ORDER BY id`)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var users []User
    for rows.Next() {
        var u User
        if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt); err != nil {
            return nil, err
        }
        users = append(users, u)
    }
    return users, rows.Err()
}
$ go run ./cmd/listusers
[{1 alice alice@example.com 2026-04-26 09:12:33.214 +0900 JST} {2 bob bob@example.com 2026-04-27 10:01:55.881 +0900 JST}]

これ自体は標準ライブラリだけで完結するので学習コスト的には正しい入り口です。ただし、SELECT のカラム順と Scan の引数順がズレた瞬間に、コンパイルは通るのに実行時に値が混じるという地味に怖いバグを生みます。emailname のカラム順を逆に書いても string 同士なので Go は何も言ってくれません。

カラム追加のたびにScanとSELECTを両方触る必要がある

もう一つの問題は、テーブルにカラムを追加するたびに、SELECT のカラム列・Scan の引数列・構造体の3か所を全て手で揃え直す必要があることです。usersupdated_at を足したら、関連する SELECT 文を全部探して直す必要が出てきます。

このあたりの繰り返しを減らしてくれるのが sqlx の StructScan 系の API です。GORM のようにマイグレーションまで面倒は見てくれませんが、SELECT 結果と構造体のマッピングだけは肩代わりしてくれます。

sqlxの4つのハンドル型と公式ガイドの読みどころ

sqlx を使い始めるときに、まず公式ガイド『Illustrated Guide to SQLX』Handle Types の章を読むのが結局いちばん早いです。ここに以下の4つの主要型が出てきます。

  • sqlx.DB: sql.DB のラッパー。コネクションプール本体
  • sqlx.Tx: sql.Tx のラッパー。1トランザクション=1接続
  • sqlx.Stmt: プリペアドステートメントのラッパー
  • sqlx.NamedStmt: 名前付きプレースホルダ用のステートメント

標準ライブラリの型と 1対1で対応 しているのがポイントで、既存の *sql.DB から sqlx.NewDb(db, "pgx") でラップすれば、既存コードを壊さずに段階的に移行できます。これは GORM への移行と比べて圧倒的に楽な部分でした。

NewDbとConnect/MustConnectの違い

初期化系のAPIで紛らわしいのが sqlx.Connectsqlx.MustConnectsqlx.NewDb の使い分けです。

関数挙動使いどころ
sqlx.Connect(driver, dsn)Open + Ping を実行し失敗時はerrormain関数の初期化
sqlx.MustConnect(driver, dsn)失敗時にpanicテストやスクリプト
sqlx.NewDb(*sql.DB, driver)既存の *sql.DB をラップ段階的移行・テスト用差し替え

本番のサービスでは sqlx.Connect を選び、Ping 失敗時にプロセスを起動させない作りにしておくのが安全です。CLIツールやテストでサクッと書きたいときだけ MustConnect を使う、という棲み分けに落ち着きました。

db タグでカラム名をマッピングする

sqlx は構造体フィールドを小文字化したものをカラム名と突き合わせますが、カラム名がスネークケースなら必ず db タグを書くべきです。

type User struct {
    ID        int64     `db:"id"`
    Name      string    `db:"name"`
    Email     string    `db:"email"`
    CreatedAt time.Time `db:"created_at"`
}

公式ドキュメントの Advanced Scanning の章にもありますが、タグを省くと CreatedAtcreatedat として扱われ、created_at カラムとはマッピングされません。地味にハマります。

Get / Select / Queryx の使い分け

sqlx の真価は GetSelect です。先ほどの listUsers を sqlx で書き直すと、ループも Scan もなくなります。

単一行は Get、複数行は Select

func listUsers(ctx context.Context, db *sqlx.DB) ([]User, error) {
    var users []User
    if err := db.SelectContext(ctx, &users,
        `SELECT id, name, email, created_at FROM users ORDER BY id`,
    ); err != nil {
        return nil, err
    }
    return users, nil
}

func getUser(ctx context.Context, db *sqlx.DB, id int64) (*User, error) {
    var u User
    if err := db.GetContext(ctx, &u,
        `SELECT id, name, email, created_at FROM users WHERE id = $1`, id,
    ); err != nil {
        return nil, err
    }
    return &u, nil
}
$ go run ./cmd/listusers
[{1 alice alice@example.com 2026-04-26 09:12:33.214 +0900 JST} {2 bob bob@example.com 2026-04-27 10:01:55.881 +0900 JST}]

注意点として、Get は結果が0件のとき sql.ErrNoRows を返します。「ユーザーが見つからない」を業務的なエラー(404 など)に変換する処理は呼び出し側で必ず書きます。

Selectはメモリに全部載る

公式ガイドが繰り返し注意しているのは、Select は結果セット全体をメモリに展開するという点です。1000行程度なら何も考えなくて良いですが、数百万行の集計テーブルを舐めるような処理に Select を使うと、簡単にOOMで落ちます。

大きな結果セットを処理するときは Queryx でカーソルを開き、StructScan でストリーミング処理する形にします。

rows, err := db.QueryxContext(ctx, `SELECT id, name, email, created_at FROM users`)
if err != nil {
    return err
}
defer rows.Close()

for rows.Next() {
    var u User
    if err := rows.StructScan(&u); err != nil {
        return err
    }
    if err := process(u); err != nil {
        return err
    }
}
return rows.Err()
processed user id=1 (alice)
processed user id=2 (bob)
... (10万行ぶん続く)

ストリーミングするときの落とし穴は、rows.Close() を defer し忘れると接続がプールに返らないことです。rows.Next()false を返したタイミングで自動でクローズされますが、途中で return する分岐があるなら必ず defer で閉じます。

NamedExec と sqlx.In でクエリを書きやすくする

INSERT・UPDATE のときに位置パラメータ $1, $2, ... を並べると、引数の順番ミスが事故の温床になります。sqlx の NamedExec を使うと :column_name という名前付きプレースホルダで書けて、構造体やマップから値を流し込めます。

NamedExecで構造体から直接INSERT

type CreateUserParams struct {
    Name  string `db:"name"`
    Email string `db:"email"`
}

func createUser(ctx context.Context, db *sqlx.DB, p CreateUserParams) error {
    _, err := db.NamedExecContext(ctx, `
        INSERT INTO users (name, email, created_at)
        VALUES (:name, :email, NOW())
    `, p)
    return err
}
$ go run ./cmd/createuser
user created: alice / alice@example.com

構造体の db タグと :name 形式のプレースホルダがマッピングされます。引数の順番にバグが入る余地がなくなり、列を1つ追加するときの修正も最小限です。

IN句は sqlx.In で展開する

サジェストでも「sqlx golang bulk insert」がよく出てきますが、関連して聞かれるのが「IN句にスライスを渡すには?」です。標準の database/sql ではプレースホルダを動的に組み立てる必要がありましたが、sqlx には sqlx.In ヘルパーがあります。

ids := []int64{1, 2, 5, 8}
query, args, err := sqlx.In(
    `SELECT id, name, email, created_at FROM users WHERE id IN (?)`, ids)
if err != nil {
    return err
}
query = db.Rebind(query) // PostgreSQLなら $1, $2, ... に変換

var users []User
if err := db.SelectContext(ctx, &users, query, args...); err != nil {
    return err
}
expanded query: SELECT id, name, email, created_at FROM users WHERE id IN ($1, $2, $3, $4)
expanded args : [1 2 5 8]

ここで重要なのが db.Rebind を必ず通すことです。sqlx.In が返すクエリは ? プレースホルダ前提なので、PostgreSQL や Oracle など別のドライバを使っているとそのままでは動きません。Rebind は接続中のドライバに合わせて $1, $2:1 に書き換えてくれます。これは公式ガイドの Bindvars 周りの章にも書かれている要注意ポイントです。

トランザクションは1接続を抱え込む前提で書く

sqlx の Beginx()*sqlx.Tx を返します。MustBegin() はエラー時にパニックする版で、テストや初期化スクリプト向きです。本番のリクエスト処理には BeginTxx(ctx, opts) を使うのが定番でした。

Tx内ではクエリを直列で流す

公式ガイドの Transactions の章にある通り、トランザクション中は1つの接続を抱え込んでいるので、同じ Tx から並行してクエリを叩くと壊れます。Rows/Row を開いたまま別のクエリを実行すると、内部的に再利用できる接続がなく、エラーになる場合があります。

func transferBalance(ctx context.Context, db *sqlx.DB, fromID, toID int64, amount int64) error {
    tx, err := db.BeginTxx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback() // Commit済みなら no-op

    if _, err := tx.ExecContext(ctx,
        `UPDATE accounts SET balance = balance - $1 WHERE id = $2`, amount, fromID,
    ); err != nil {
        return err
    }
    if _, err := tx.ExecContext(ctx,
        `UPDATE accounts SET balance = balance + $1 WHERE id = $2`, amount, toID,
    ); err != nil {
        return err
    }
    return tx.Commit()
}
$ go run ./cmd/transfer -from=1 -to=2 -amount=500
transfer ok: 1 -> 2, amount=500

defer tx.Rollback() を最初に書いておくのは database/sql 時代からのイディオムですが、sqlx でもそのまま使えます。Commit 済みのトランザクションに対する Rollback は no-op として無視されるので、安全に二重に書けるのがポイントです。

サブ関数にTxを渡すならインターフェースで揃える

*sqlx.DB*sqlx.Tx はメソッドセットが似ていますが完全に同じ型ではないので、ヘルパー関数に渡すときは自前で小さなインターフェースを定義しておくと便利です。

type Querier interface {
    GetContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error
    SelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error
    NamedExecContext(ctx context.Context, query string, arg interface{}) (sql.Result, error)
}

こうしておくと、テストでは sqlx.DB を渡し、本番のリクエスト処理では sqlx.Tx を渡す、というかたちで関数を使い回せます。

GORMと比較してsqlxを選ぶ判断軸

sqlx と GORM は守備範囲が違うので、宗教戦争にせず手元のプロジェクトの性質で選ぶのが現実的です。実際にチームで議論した観点を表にしておきます。

観点sqlxGORM
SQLの可視性常に生SQLを書くメソッドチェインの裏に隠れる
型安全StructScanのみ。型はクエリで担保モデル定義で担保
マイグレーション別ツール(goose, golang-migrate等)AutoMigrateあり
N+1の検知SQLが見えるので気付きやすいPreload漏れに注意
学習コストSQLが書ければOKGORM独自APIを覚える

個人的な判断基準は「リードレプリカ・ウィンドウ関数・複雑なJOINを書く頻度が多いか?」です。多いなら sqlx。シンプルなCRUDが大半でマイグレーションも一気通貫したいなら GORM、という分け方に落ち着いています。GORM 側の N+1 については、関連記事として GORMのN+1問題とマイグレーション設計Go のデータベース接続プーリングで何が変わる? も比較材料になるはずです。

移行手順—database/sql → sqlx をミニマムに進める

既存リポジトリを段階的に sqlx に寄せるときの実際の手順です。一気に書き換えると差分が膨大になるので、コミット粒度を意識して進めるのが安全でした。

1. NewDbで包んでビルドが通ることを確認する

// 既存
// db, err := sql.Open("pgx", dsn)

// 移行ステップ1: sqlx.NewDbで包むだけ
std, err := sql.Open("pgx", dsn)
if err != nil {
    return err
}
db := sqlx.NewDb(std, "pgx")

この時点では既存のメソッド呼び出し(db.QueryContext など)はそのまま動くはずです。ここでビルドが落ちる場合は、依存先で *sql.DB を直接受けている箇所があるので、まずはそこを *sqlx.DB に書き換えるか、db.DB で生の *sql.DB を取り出して渡します。

2. 読み取り系を Get/Select に置き換える

SELECT 文と Scan ループの組み合わせを GetContext/SelectContext に置き換えます。1ファイルずつコミットしていけば、レビューもしやすく、ロールバックも容易です。

3. 書き込み系をNamedExecに揃える

INSERT・UPDATE は名前付きプレースホルダに統一すると、引数順ミスを根絶できます。テストカバレッジが薄いところは、まず ExecContext のままにしておき、CRUD のうちカラムが多い大物テーブル(5カラム以上の目安)から先に NamedExec に倒す、という順序が取り回しやすかったです。

使ってみないと気づきにくい知見

  • Get は0件で sql.ErrNoRows を返す: errors.Is(err, sql.ErrNoRows) で必ず分岐する。これを忘れると404にすべきレスポンスが500になります
  • db タグの綴りミスは黙って無視される: タグ名が間違っていてもコンパイルは通り、実行時にゼロ値が入ったまま処理が進みます。テストで assert.Equal しないと気付けません
  • 埋め込み構造体もStructScanの対象: type Audited struct { CreatedAt time.Time } のような共通フィールドを埋め込むと、その db タグも有効になります。共通カラムを抽出しやすくなります
  • sqlx.In の戻り値は必ず Rebind を通す: PostgreSQL を使っていて忘れると syntax error at or near "?" という謎エラーに直面します
  • NamedQuery はカーソルを返す: NamedExec(更新系)とは違い、NamedQuery*sqlx.Rows を返すのでループ処理が必要です。名前が紛らわしいので注意

まとめ

  • sqlx は database/sql の薄いラッパーで、既存コードを sqlx.NewDb で包めば段階的に移行できる
  • 読み取りは GetContext/SelectContext、書き込みは NamedExecContext に揃えると、Scan の引数順事故と位置パラメータの順番ミスを同時に潰せる
  • 大きな結果セットは Select ではなく QueryxContext + StructScan でストリーミング処理する
  • IN 句は sqlx.In で展開し、必ず db.Rebind を通してドライバに合わせた構文に変換する
  • トランザクションは1接続を抱え込むので、Tx 中はクエリを直列で流し、defer tx.Rollback() を入口に置くのが定番イディオム
  • SQL を可視化したまま書きたい・複雑な JOIN が多い案件では sqlx、AutoMigrate も込みで楽したいなら GORM、という棲み分けで選ぶ

sqlx は ORM ほど学習コストが高くないわりに、database/sql の素朴な書き心地を素直に拡張してくれます。最初に Get/Select/NamedExec の3つだけ覚えれば、9割の場面はカバーできるはずです。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次