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.QueryRowContext | 1 行を返す SELECT。Scan でエラーを返す |
db.ExecContext | INSERT / 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 に近い |
| sqlx | database/sql の拡張。構造体への Scan を簡略化する | Dapper に近い |
| sqlc | SQL から型安全なコードを生成 | 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 またはライブラリで適用する |
| goose | SQL ファイルまたは Go のコードで書くマイグレーション。CLI で操作する |
| Atlas | スキーマの宣言から差分を計算する、宣言的なマイグレーション |
- 一般的には、SQL ファイルとしてバージョン管理し、デプロイのパイプラインで適用します。
- トランザクションは
db.BeginTx(ctx, nil)で開始し、tx.Commit()またはtx.Rollback()で終了します。TransactionScopeのような暗黙的なアンビエントトランザクションはなく、txを明示的に引き回します。
2. .NET との対応表
| .NET (C#) | Go |
|---|---|
| ADO.NET | database/sql |
| Dapper | sqlx |
| EF Core | GORM / ent / sqlc (コード生成) |
| EF Migrations | golang-migrate / atlas / goose |
上の表を補う対応関係です。
| .NET (C#) | Go |
|---|---|
DbConnection | *sql.DB (プール) / *sql.Conn (単一接続) |
DbCommand.ExecuteNonQuery | db.ExecContext |
DbCommand.ExecuteReader | db.QueryContext (*sql.Rows) |
DbDataReader.Read() / GetXxx() | rows.Next() / rows.Scan(&...) |
ExecuteScalar | db.QueryRowContext(...).Scan(&v) |
DbTransaction | *sql.Tx |
TransactionScope | なし (tx を明示的に引き回す) |
DBNull / Nullable<T> | sql.NullString などの sql.Null[T] (Go 1.22 以降) / ポインター |
接続文字列の Max Pool Size | db.SetMaxOpenConns |
CancellationToken | context.Context (...Context 系メソッドの第 1 引数) |
DbContext | 対応する型はなし (GORM の *gorm.DB、sqlc の *Queries が近い) |
変更追跡 (ChangeTracker) | なし (GORM は都度 Save / Updates を呼ぶ) |
| LINQ to Entities | ent の型安全なクエリ 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 ファイルとして管理し、デプロイのパイプラインで適用します。