メインコンテンツまでスキップ

Goのデータアクセス

.NET (C#) の経験がある開発者向けに、database/sql、sqlc、GORM、ent、EF Core/Dapper との対応を解説します。.NET では EF Core を標準的な選択肢として使う場面が多い一方、Go では標準の database/sql を土台にして、SQL を直接書く方法から、SQL をコード生成する sqlc、フル機能の ORM まで、複数の流派が共存しています。Go のコミュニティーでは「SQL を明示的に書き、変更追跡や遅延読み込みのような暗黙の挙動を持ち込まない」方針が好まれる傾向があります。ここでは、各アプローチの考え方と選び方を整理します。

1. このドキュメントで扱うこと​

  • database/sql とコネクションプール
  • sqlc によるSQL先行のアプローチ
  • ORM(GORM/ent)を選ぶ基準
  • マイグレーション管理とトランザクション

database/sql とコネクションプール​

database/sql は、特定のデータベースに依存しない汎用的なインターフェースで、ADO.NET に近い立ち位置です。実際に接続するには、データベースごとの「ドライバー」を空の import (_) で登録します。

  • sql.Open(driverName, dsn) が返す *sql.DB は、単一の接続ではなく、コネクションプールを表します。複数のゴルーチンから安全に共有でき、アプリケーションの起動時に一度だけ作って使い回します。
  • sql.Open は接続の検証をしません。接続できるかは、db.PingContext(ctx) で確認します。
  • 主な API は次のとおりです。
API用途
db.QueryContext複数行を返す SELECT。*sql.Rows を返す
db.QueryRowContext1 行を返す SELECT。Scan でエラーを返す
db.ExecContextINSERT / UPDATE / DELETE などの行を返さない文
db.BeginTxトランザクションの開始
db.PrepareContextプリペアドステートメント
  • プールは、SetMaxOpenConns、SetMaxIdleConns、SetConnMaxLifetime、SetConnMaxIdleTime で調整します。
  • クエリのプレースホルダーは、ドライバーごとに異なります。PostgreSQL は $1、$2、MySQL や SQLite は ?、SQL Server は @p1 や @name です。

sqlc によるSQL先行のアプローチ​

sqlc は、SQL を書いて、そこから型安全な Go のコードを生成するツールです。ORM とは逆の発想で、「SQL が先で、コードが後」になります。

  • スキーマ (CREATE TABLE) とクエリ (SELECT ...) を .sql ファイルに書き、sqlc generate を実行します。
  • クエリごとに、構造体、引数の型、メソッドが生成されます。SQL とスキーマが合わなければ、生成時にエラーになるため、型の食い違いをビルド前に検出できます。
  • 実行時は生成されたコードが database/sql (または pgx) を呼ぶだけで、リフレクションや実行時の SQL 生成がありません。
  • 動的な条件組み立て (検索条件が可変の場合など) は不得意で、そうした箇所では database/sql を直接使うか、クエリビルダーを併用します。

ORM(GORM/ent)を選ぶ基準​

選択肢特徴EF Core との対比
database/sql 直接最も明示的。ボイラープレートが多いADO.NET に近い
sqlxdatabase/sql の拡張。構造体への Scan を簡略化するDapper に近い
sqlcSQL から型安全なコードを生成EF Core よりも、型付きの Dapper に近い
GORMリフレクションベースの ORM。AutoMigrate、リレーション、フックなどを備えるEF Core に最も近い
entスキーマをコードで定義し、型安全な API を生成する。グラフ的なデータ構造に強いLINQ に近い型安全なクエリが得られる

選択の目安は次のとおりです。

  • SQL を書くことに抵抗がなく、クエリの内容を明確に管理したいなら、sqlc が有力です。性能や挙動を予測しやすく、レビューもしやすくなります。
  • CRUD 中心で素早く作りたい、リレーションの扱いを任せたいなら GORM を検討します。ただし、リフレクションによる暗黙の挙動 (デフォルトのテーブル名、論理削除、ゼロ値の更新など) を理解した上で使う必要があります。
  • スキーマ駆動で型安全性を重視するなら ent が候補になります。
  • どれを選んでも、複雑なレポートクエリなどでは、生の SQL を書く場面が残ります。

マイグレーション管理とトランザクション​

EF Migrations のように、モデルの差分から自動でマイグレーションを生成する仕組みは、database/sql の範囲にはありません。マイグレーションは、別のツールで管理します。

ツール特徴
golang-migrate番号付きの up / down の SQL ファイルを、CLI またはライブラリで適用する
gooseSQL ファイルまたは Go のコードで書くマイグレーション。CLI で操作する
Atlasスキーマの宣言から差分を計算する、宣言的なマイグレーション
  • 一般的には、SQL ファイルとしてバージョン管理し、デプロイのパイプラインで適用します。
  • トランザクションは db.BeginTx(ctx, nil) で開始し、tx.Commit() または tx.Rollback() で終了します。TransactionScope のような暗黙的なアンビエントトランザクションはなく、tx を明示的に引き回します。

2. .NET との対応表​

.NET (C#)Go
ADO.NETdatabase/sql
Dappersqlx
EF CoreGORM / ent / sqlc (コード生成)
EF Migrationsgolang-migrate / atlas / goose

上の表を補う対応関係です。

.NET (C#)Go
DbConnection*sql.DB (プール) / *sql.Conn (単一接続)
DbCommand.ExecuteNonQuerydb.ExecContext
DbCommand.ExecuteReaderdb.QueryContext (*sql.Rows)
DbDataReader.Read() / GetXxx()rows.Next() / rows.Scan(&...)
ExecuteScalardb.QueryRowContext(...).Scan(&v)
DbTransaction*sql.Tx
TransactionScopeなし (tx を明示的に引き回す)
DBNull / Nullable<T>sql.NullString などの sql.Null[T] (Go 1.22 以降) / ポインター
接続文字列の Max Pool Sizedb.SetMaxOpenConns
CancellationTokencontext.Context (...Context 系メソッドの第 1 引数)
DbContext対応する型はなし (GORM の *gorm.DB、sqlc の *Queries が近い)
変更追跡 (ChangeTracker)なし (GORM は都度 Save / Updates を呼ぶ)
LINQ to Entitiesent の型安全なクエリ API / GORM のメソッドチェーン / sqlc では SQL そのもの
Include (Eager Loading)GORM の Preload / ent の WithXxx / sqlc では JOIN を書く
DbUpdateException (一意制約違反など)ドライバー固有のエラー型 (pgconn.PgError など) を errors.As で判定

3. C# 開発者がつまずきやすいポイント​

sql.DB は接続ではなくプール​

*sql.DB は、リクエストごとに開いたり閉じたりするものではありません。起動時に一度作り、アプリケーション全体で共有します。Close は、シャットダウン時に呼ぶだけです。using var conn = new SqlConnection(...) の感覚で、処理のたびに sql.Open して Close するのは、プールの利点を失うためやってはいけません。

rows.Close() と rows.Err() を忘れない​

QueryContext が返す *sql.Rows は、使い終えたら Close が必要で、defer rows.Close() が定石です。rows.Next() が false を返したときは、ループが正常に終わったのか、エラーで中断したのかを、rows.Err() で確認します。

行がないことはエラー (sql.ErrNoRows) で返る​

QueryRowContext(...).Scan(...) で行が見つからないと、sql.ErrNoRows が返ります。FirstOrDefault のように、null が返るわけではありません。errors.Is(err, sql.ErrNoRows) で判定し、「見つからない」を別のエラー (または nil とブール値) に変換します。

NULL の扱いが煩雑​

NULL の可能性がある列は、string や int に直接 Scan できません (エラーになります)。sql.NullString、sql.NullInt64、Go 1.22 以降の sql.Null[T]、またはポインター (*string) を使います。

Scan は列の順序と型に依存する​

SELECT * は列の順序が変わったときに壊れるため、列名を明示します。Scan の引数の数と順序が、SELECT した列と一致する必要があります。不一致は、コンパイル時ではなく実行時にエラーになります。

トランザクションの中では tx を使う​

tx, _ := db.BeginTx(...) の後に db.ExecContext を呼ぶと、そのクエリはトランザクションの外で実行されます。必ず tx.ExecContext を使います。また、defer tx.Rollback() を最初に書いておくと、Commit 済みの場合は sql.ErrTxDone が返るだけで無害のため、途中の return や panic でもロールバックされます。

context を渡す​

db.Query ではなく、db.QueryContext(ctx, ...) を使います。リクエストの ctx を渡すことで、クライアントが切断されたり、タイムアウトしたりしたときに、クエリも中断できます。

コネクションプールの上限を設定する​

デフォルトでは、MaxOpenConns に上限がなく、負荷によっては、データベース側の接続数を使い切ります。接続数の上限と、SetConnMaxLifetime (ロードバランサーや DB 側のタイムアウトより短くする) を設定するのが一般的です。

ORM の暗黙の挙動に注意​

GORM は、Go のゼロ値 (0、空文字列、false) を、構造体で Updates したときに更新対象から外します。「0 に更新したい」場合は、map を使うか、Select で列を指定します。EF Core のような変更追跡はなく、更新は呼び出しごとに明示的です。

LINQ のような式木ベースのクエリはない​

Where(u => u.Age > 20) のような、ラムダ式からクエリを組み立てる仕組みは、Go にはありません。ORM は、メソッドチェーンと文字列 (Where("age > ?", 20)) で条件を書くか、スキーマから生成された型付きの API を使います。sqlc では、SQL をそのまま書きます。

マイグレーションは別管理​

モデルを変更しても、自動でマイグレーションが生成されることはありません。GORM の AutoMigrate は、開発用途には便利ですが、列の削除などはせず、本番の運用には向きません。本番では、SQL ファイルとして明示的にバージョン管理されたマイグレーションを使うのが一般的です。

4. コード例​

例 1: database/sql の基本 (接続、クエリ、スキャン)​

C# (Dapper):

await using var conn = new NpgsqlConnection(connectionString);

var user = await conn.QuerySingleOrDefaultAsync<User>(
"SELECT id, name, email FROM users WHERE id = @id", new { id = 1 });

var users = await conn.QueryAsync<User>(
"SELECT id, name, email FROM users WHERE age >= @min", new { min = 20 });

Go (database/sql):

package main

import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"time"

_ "github.com/jackc/pgx/v5/stdlib" // ドライバーを "pgx" として登録する
)

type User struct {
ID int
Name string
Email string
}

var ErrUserNotFound = errors.New("user not found")

func GetUser(ctx context.Context, db *sql.DB, id int) (*User, error) {
var u User
err := db.QueryRowContext(ctx,
"SELECT id, name, email FROM users WHERE id = $1", id,
).Scan(&u.ID, &u.Name, &u.Email)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
if err != nil {
return nil, fmt.Errorf("get user %d: %w", id, err)
}
return &u, nil
}

func ListUsers(ctx context.Context, db *sql.DB, minAge int) ([]User, error) {
rows, err := db.QueryContext(ctx,
"SELECT id, name, email FROM users WHERE age >= $1 ORDER BY id", minAge)
if err != nil {
return nil, fmt.Errorf("list users: %w", err)
}
defer rows.Close()

var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name, &u.Email); err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
users = append(users, u)
}
// ループがエラーで中断されていないか確認する
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate users: %w", err)
}
return users, nil
}

func main() {
db, err := sql.Open("pgx", "postgres://user:pass@localhost:5432/app?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()

// プールの設定
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(30 * time.Minute)

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

// sql.Open は接続しないため、Ping で疎通を確認する
if err := db.PingContext(ctx); err != nil {
log.Fatal(err)
}

u, err := GetUser(ctx, db, 1)
switch {
case errors.Is(err, ErrUserNotFound):
fmt.Println("見つかりません")
case err != nil:
log.Fatal(err)
default:
fmt.Println(u.Name)
}
}

INSERT / UPDATE / DELETE は ExecContext を使います。影響行数は result.RowsAffected() で取得できます。PostgreSQL で挿入した行の ID を取得するには、INSERT ... RETURNING id を QueryRowContext で実行します。

例 2: NULL 許容列と sqlx​

C#:

public record Profile(int Id, string? Bio, int? Age);

Go では、NULL を受け取る型を選びます。

package main

import (
"context"
"database/sql"
)

type Profile struct {
ID int
Bio sql.NullString // Valid が false なら NULL
Age *int // nil なら NULL
}

func GetProfile(ctx context.Context, db *sql.DB, id int) (Profile, error) {
var p Profile
err := db.QueryRowContext(ctx,
"SELECT id, bio, age FROM profiles WHERE id = $1", id,
).Scan(&p.ID, &p.Bio, &p.Age)
return p, err
}

func (p Profile) BioOrDefault() string {
if p.Bio.Valid {
return p.Bio.String
}
return "(なし)"
}

Dapper に近い使い心地が欲しい場合は、サードパーティーの sqlx (github.com/jmoiron/sqlx) が、構造体への自動マッピングを提供します。

// sqlx の例 (github.com/jmoiron/sqlx)
type User struct {
ID int `db:"id"`
Name string `db:"name"`
Email string `db:"email"`
}

var u User
err := db.GetContext(ctx, &u, "SELECT id, name, email FROM users WHERE id = $1", 1)

var users []User
err = db.SelectContext(ctx, &users, "SELECT id, name, email FROM users WHERE age >= $1", 20)

例 3: トランザクション​

C#:

await using var tx = await conn.BeginTransactionAsync();
try
{
await conn.ExecuteAsync("UPDATE accounts SET balance = balance - @amt WHERE id = @from",
new { amt, from }, tx);
await conn.ExecuteAsync("UPDATE accounts SET balance = balance + @amt WHERE id = @to",
new { amt, to }, tx);
await tx.CommitAsync();
}
catch
{
await tx.RollbackAsync();
throw;
}

Go では、トランザクションをヘルパー関数にまとめると、コミットとロールバックの書き忘れを防げます。

package main

import (
"context"
"database/sql"
"fmt"
)

// WithTx は fn が成功すればコミットし、エラーや panic ならロールバックする。
func WithTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) (err error) {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}

defer func() {
if p := recover(); p != nil {
_ = tx.Rollback()
panic(p) // panic は呼び出し元へ再送出する
}
if err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
err = fmt.Errorf("%w (rollback: %v)", err, rbErr)
}
}
}()

if err = fn(tx); err != nil {
return err
}
if err = tx.Commit(); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
}

func Transfer(ctx context.Context, db *sql.DB, from, to int, amount int64) error {
return WithTx(ctx, db, func(tx *sql.Tx) error {
// ここでは db ではなく tx を使う
if _, err := tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance - $1 WHERE id = $2", amount, from); err != nil {
return fmt.Errorf("debit: %w", err)
}
if _, err := tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, to); err != nil {
return fmt.Errorf("credit: %w", err)
}
return nil
})
}

fn の中で db を使うと、トランザクション外で実行されます。リポジトリー層を設計するときは、*sql.DB と *sql.Tx の両方が満たす、ExecContext、QueryContext、QueryRowContext を持つ小さなインターフェースを定義して、どちらも受け取れるようにする方法がよく使われます。

type DBTX interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

例 4: sqlc で SQL から Go のコードを生成する​

設定ファイル sqlc.yaml:

version: "2"
sql:
- engine: "postgresql"
queries: "db/query.sql"
schema: "db/schema.sql"
gen:
go:
package: "db"
out: "db"
sql_package: "pgx/v5"

スキーマ db/schema.sql:

CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);

クエリ db/query.sql (コメントでクエリ名と戻り値の形を指定します):

-- name: GetUser :one
SELECT id, name, email FROM users WHERE id = $1;

-- name: ListUsers :many
SELECT id, name, email FROM users ORDER BY id;

-- name: CreateUser :one
INSERT INTO users (name, email) VALUES ($1, $2)
RETURNING id, name, email;

-- name: DeleteUser :exec
DELETE FROM users WHERE id = $1;

sqlc generate を実行すると、db パッケージに、User 構造体、CreateUserParams、Queries 型とそのメソッドが生成されます。呼び出し側は次のようになります。

queries := db.New(pool) // pool は *pgxpool.Pool。トランザクション内では queries.WithTx(tx)

user, err := queries.CreateUser(ctx, db.CreateUserParams{
Name: "Alice",
})
if err != nil {
return err
}

got, err := queries.GetUser(ctx, user.ID)
users, err := queries.ListUsers(ctx)
  • :one は 1 行、:many は複数行、:exec は結果を返さない文に対応します。
  • SQL を直接レビューでき、生成されたコードは database/sql や pgx の呼び出しだけなので、挙動が追いやすいのが利点です。
  • スキーマを変更したら、sqlc generate を再実行します。型が合わなくなったクエリは、生成時またはコンパイル時にエラーになります。

例 5: GORM と ent の雰囲気​

EF Core:

var user = await db.Users.FirstOrDefaultAsync(u => u.Email == email);
user.Name = "Alice";
await db.SaveChangesAsync();

GORM (gorm.io/gorm):

// 接続 (gorm.io/driver/postgres)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})

type User struct {
ID uint `gorm:"primaryKey"`
Name string
Email string `gorm:"uniqueIndex"`
}

// 取得 (必ず WithContext で ctx を渡す)
var user User
err = db.WithContext(ctx).Where("email = ?", email).First(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// 見つからない
}

// 更新 (変更追跡はないため、明示的に更新する)
db.WithContext(ctx).Model(&user).Update("name", "Alice")

// 複数の列を更新する場合、構造体のゼロ値は無視される。0 や false を設定したいときは map を使う
db.WithContext(ctx).Model(&user).Updates(map[string]any{"name": "Alice", "age": 0})

ent (entgo.io/ent) では、スキーマを Go のコードで定義し、そこから型安全なクライアントが生成されます。

// ent で生成されたクライアントを使う例
u, err := client.User.
Query().
Where(user.EmailEQ(email)).
Only(ctx)
  • どちらのコードも、利用側のプロジェクトにパッケージを追加し、ent の場合はコード生成を行ってから使います。
  • ORM を採用する場合でも、複雑なクエリでは、生の SQL を実行する手段 (GORM の Raw、Exec など) を併用します。

例 6: マイグレーションの運用​

golang-migrate の CLI の例です。

# 新しいマイグレーションファイル (up / down のペア) を作る
migrate create -ext sql -dir db/migrations -seq create_users

# 適用する
migrate -path db/migrations -database "$DATABASE_URL" up

# 1 つ戻す
migrate -path db/migrations -database "$DATABASE_URL" down 1

db/migrations/000001_create_users.up.sql と 000001_create_users.down.sql に、それぞれ適用と取り消しの SQL を書きます。goose や Atlas でも、SQL ファイルをリポジトリーに置き、CI/CD から適用する流れは共通です。

# goose の例
goose -dir db/migrations postgres "$DATABASE_URL" up

# Atlas の例 (ディレクトリ形式のマイグレーションを適用する)
atlas migrate apply --dir "file://db/migrations" --url "$DATABASE_URL"

アプリケーションの起動時に自動でマイグレーションを適用する場合は、複数のインスタンスが同時に起動しても安全なように、ツールのロック機構に頼るか、デプロイのジョブとして分離します。

5. まとめ​

  • database/sql は、ADO.NET に近い標準の抽象です。*sql.DB は接続ではなくプールで、起動時に一度作って共有します。
  • クエリには ...Context 系のメソッドを使って ctx を渡し、rows.Close() と rows.Err()、sql.ErrNoRows の判定を忘れないようにします。
  • NULL は、sql.NullString や sql.Null[T]、ポインターで受け取ります。
  • トランザクションは *sql.Tx を明示的に引き回します。defer で Rollback を保証するヘルパー関数にまとめると安全です。
  • Go には LINQ や変更追跡に相当する標準機能はありません。Dapper 風なら sqlx、SQL を先に書いて型安全なコードを得るなら sqlc、EF Core 風なら GORM や ent を選びます。
  • ORM の暗黙の挙動 (ゼロ値の扱い、AutoMigrate など) を理解した上で採用し、複雑なクエリでは生の SQL を併用します。
  • マイグレーションは、golang-migrate、goose、Atlas などで SQL ファイルとして管理し、デプロイのパイプラインで適用します。

参考資料​