database/sql — pola dasar
database/sql bukan ORM. Ia lapisan tipis di atas driver, dengan connection pool di dalamnya — dan justru ketipisannya yang membuat setiap perilaku bisa kamu prediksi.
Intisari
*sql.DBbukan satu koneksi — ia pool. Buat sekali, pakai bersama, jangan pernah tutup di tengah aplikasi.- Selalu pakai varian
...Context:QueryContext,ExecContext,QueryRowContext. rows.Close()wajib, danrows.Err()wajib diperiksa setelah loop selesai.- Parameter selalu lewat placeholder (
$1di Postgres). Menyambung string SQL adalah SQL injection. - Kolom yang bisa NULL butuh
sql.Null[T]atau pointer —Scankestringbiasa akan gagal.
Membuka pool
import (
"database/sql"
_ "github.com/jackc/pgx/v5/stdlib" // driver mendaftarkan diri lewat init()
)
func BukaDB(ctx context.Context, dsn string) (*sql.DB, error) {
db, err := sql.Open("pgx", dsn) // TIDAK menyambung — hanya menyiapkan pool
if err != nil {
return nil, fmt.Errorf("buka db: %w", err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(30 * time.Minute)
db.SetConnMaxIdleTime(5 * time.Minute)
// Sambungan pertama baru terjadi di sini — dan di sinilah kredensial
// yang salah akhirnya ketahuan.
ctx, batal := context.WithTimeout(ctx, 5*time.Second)
defer batal()
if err := db.PingContext(ctx); err != nil {
return nil, fmt.Errorf("ping db: %w", err)
}
return db, nil
}
sql.Open hampir tidak pernah gagal. Ia cuma memvalidasi nama driver. Kalau host
salah, sandi salah, atau security group memblokir, kamu baru tahu saat kueri pertama — yaitu saat
permintaan pengguna pertama. Karena itu PingContext saat startup bukan hiasan: ia mengubah
kesalahan konfigurasi jadi container yang gagal start, bukan 500 yang menetes ke pengguna.
Garis bawah di _ "github.com/jackc/pgx/v5/stdlib" berarti "impor untuk efek
sampingnya" — paket itu memanggil sql.Register di init(). Tanpa impor itu,
sql.Open("pgx", ...) gagal dengan "unknown driver".
Satu baris
func (r *Repo) Ambil(ctx context.Context, id int64) (Produk, error) {
var p Produk
err := r.db.QueryRowContext(ctx,
`SELECT id, nama, harga, dibuat FROM produk WHERE id = $1`, id,
).Scan(&p.ID, &p.Nama, &p.Harga, &p.Dibuat)
switch {
case errors.Is(err, sql.ErrNoRows):
return Produk{}, fmt.Errorf("produk %d: %w", id, ErrTidakDitemukan)
case err != nil:
return Produk{}, fmt.Errorf("ambil produk %d: %w", id, err)
}
return p, nil
}
QueryRowContext tidak butuh Close: koneksinya dikembalikan otomatis saat
Scan selesai. Ini alasan ia lebih disukai kapan pun kamu memang hanya butuh satu baris.
Banyak baris
func (r *Repo) Cari(ctx context.Context, kata string, batas int) ([]Produk, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, nama, harga
FROM produk
WHERE nama ILIKE $1 AND dihapus_pada IS NULL
ORDER BY harga
LIMIT $2`, "%"+kata+"%", batas)
if err != nil {
return nil, fmt.Errorf("cari produk: %w", err)
}
defer rows.Close() // ← WAJIB. Tanpa ini koneksinya tidak kembali.
hasil := make([]Produk, 0, batas) // bukan nil: JSON-nya jadi [] bukan null
for rows.Next() {
var p Produk
if err := rows.Scan(&p.ID, &p.Nama, &p.Harga); err != nil {
return nil, fmt.Errorf("scan produk: %w", err)
}
hasil = append(hasil, p)
}
// ← WAJIB. rows.Next() mengembalikan false BAIK saat selesai MAUPUN
// saat terjadi error di tengah. Tanpa baris ini, kegagalan jaringan
// di tengah pembacaan terlihat seperti "hasilnya memang cuma segitu".
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("baca hasil: %w", err)
}
return hasil, nil
}
Dua baris wajib itu adalah bug paling mahal di lapisan database Go.
defer rows.Close()yang hilang → koneksi tidak pernah kembali ke pool. SetelahMaxOpenConnspermintaan, seluruh aplikasi berhenti melayani sambil menunggu koneksi yang tidak akan pernah bebas. Gejalanya: aplikasi berjalan normal berjam-jam lalu membeku total.rows.Err()yang hilang → kegagalan di tengah pembacaan menghasilkan data yang tidak lengkap tanpa satu pun error. Laporan yang kurang barisnya, dan tidak ada yang tahu.
NULL
// Kolom yang bisa NULL tidak bisa di-Scan ke string/int biasa:
// "converting NULL to string is unsupported"
type Produk struct {
ID int64
Nama string
Deskripsi sql.Null[string] // Go 1.22+ generik
Diskon sql.Null[int64]
Dihapus *time.Time // pointer: nil = NULL
}
rows.Scan(&p.ID, &p.Nama, &p.Deskripsi, &p.Diskon, &p.Dihapus)
if p.Deskripsi.Valid {
fmt.Println(p.Deskripsi.V)
}
Cara termurah menghindari seluruh urusan ini: jangan izinkan NULL di skema. Beri kolom teks
NOT NULL DEFAULT '' dan kolom angka NOT NULL DEFAULT 0 kecuali "tidak ada
nilai" memang punya arti bisnis yang berbeda dari "kosong". Setiap kolom nullable menambah percabangan
di setiap tempat yang membacanya.
Menulis
// INSERT dengan nilai kembalian — di Postgres pakai RETURNING,
// BUKAN LastInsertId (yang tidak didukung driver Postgres).
func (r *Repo) Buat(ctx context.Context, p *Produk) error {
return r.db.QueryRowContext(ctx, `
INSERT INTO produk (nama, harga)
VALUES ($1, $2)
RETURNING id, dibuat`, p.Nama, p.Harga,
).Scan(&p.ID, &p.Dibuat)
}
// UPDATE — selalu periksa berapa baris yang benar-benar terpengaruh.
func (r *Repo) Ubah(ctx context.Context, p Produk) error {
res, err := r.db.ExecContext(ctx,
`UPDATE produk SET nama = $1, harga = $2 WHERE id = $3`,
p.Nama, p.Harga, p.ID)
if err != nil {
return fmt.Errorf("ubah produk %d: %w", p.ID, err)
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("produk %d: %w", p.ID, ErrTidakDitemukan)
}
return nil
}
RowsAffected() == 0 hampir selalu berarti sesuatu. Baris tidak ada, sudah dihapus,
atau — di kueri yang membatasi kepemilikan — milik orang lain. Mengabaikannya berarti API-mu
membalas 200 untuk operasi yang tidak terjadi. Ini juga pola otorisasi terkuat: WHERE id = $1 AND
pemilik_id = $2 membuat data milik orang lain tidak pernah ditemukan, bukan sekadar ditolak
(Fase 6).
SQL injection: satu aturan
// ❌ SQL injection. Selalu. Tanpa pengecualian.
q := "SELECT * FROM produk WHERE nama = '" + kata + "'"
// ✅ placeholder — nilai dikirim TERPISAH dari teks kueri
r.db.QueryContext(ctx, "SELECT * FROM produk WHERE nama = $1", kata)
| Database | Placeholder |
|---|---|
| PostgreSQL | $1, $2 |
| MySQL / SQLite | ? |
| SQL Server | @p1 |
Placeholder tidak bisa dipakai untuk nama tabel, nama kolom, atau arah ORDER BY.
Untuk pengurutan dinamis, jangan pernah menyambung input pengguna — petakan lewat daftar putih:
urutan := map[string]string{
"harga": "harga ASC",
"-harga": "harga DESC",
"terbaru": "dibuat DESC",
}[r.URL.Query().Get("urut")]
if urutan == "" {
urutan = "dibuat DESC" // default aman
}
Batch: satu perjalanan, bukan seribu
// ❌ N+1: satu kueri per produk. Di AWS, 200 produk × 2 ms latensi
// jaringan = 400 ms yang hilang begitu saja.
for _, id := range ids {
p, _ := repo.Ambil(ctx, id)
}
// ✅ satu kueri untuk semuanya
rows, err := db.QueryContext(ctx,
`SELECT id, nama, harga FROM produk WHERE id = ANY($1)`,
pq.Array(ids))
Ini bentuk N+1 di Go. Tidak ada lazy loading yang melakukannya diam-diam seperti di ORM — di Go, N+1 selalu berupa loop yang terlihat jelas di kode. Itu keuntungan: ia bisa ditemukan dengan membaca, dan bisa dijaga dengan tes yang menghitung jumlah kueri (Fase 8).
Latihan: tulis repository dengan Ambil, Cari, dan Buat
untuk tabel produk di Postgres lokal. Lalu sengaja hapus defer rows.Close(),
setel SetMaxOpenConns(2), dan panggil Cari tiga kali — buktikan panggilan
ketiga menggantung sampai ctx habis. Itu bentuk kegagalan yang akan kamu kenali seumur
hidup.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.