Belajar Golang dari Nol #12: Koneksi Database dengan database/sql
Di Belajar Golang dari Nol #11 kita sudah membuat REST API produk yang berjalan. Handler-nya rapi, JSON-nya jalan, endpoint-nya lengkap. Tapi ada satu masalah besar yang sengaja saya tunda. Data produk disimpan di slice, alias di memory. Begitu server dimatikan atau restart, semua data hilang. Di bagian ini kita selesaikan masalah itu. Kita sambungkan API ke database sungguhan pakai package standar database/sql.
Kenapa Harus Database
Menyimpan data di slice itu cukup untuk belajar. Untuk aplikasi nyata, tidak. Ada tiga alasan utama kenapa kita butuh database.
- Data bertahan setelah restart. Database menyimpan data ke file di disk, bukan ke RAM. Server mati, data tetap ada.
- Data bisa dicari dan difilter. Mau cari produk dengan stok di bawah 5? Satu query selesai. Kalau pakai slice, Anda harus tulis loop sendiri setiap kali.
- Aman diakses banyak proses. Database dirancang untuk menangani banyak koneksi sekaligus. Urusan lock dan konsistensi sudah diurus di level database.
Di Go, akses database lewat package database/sql. Package ini menarik karena isinya hampir semuanya interface, materi yang sudah kita bahas di bagian 9. database/sql hanya mendefinisikan kontrak: cara buka koneksi, cara kirim query, cara baca hasil. Implementasi aslinya disediakan oleh driver, yaitu package terpisah yang tahu cara bicara dengan database tertentu. Satu kontrak, banyak implementasi. Kode Anda relatif sama mau databasenya apa pun.
Di tutorial ini kita pakai SQLite lewat driver modernc.org/sqlite. Alasannya praktis. SQLite menyimpan seluruh database dalam satu file, jadi Anda tidak perlu install dan menjalankan server database apa pun. Polanya persis sama untuk MySQL atau PostgreSQL, tinggal ganti driver dan connection string-nya.
Install Driver dan Buka Koneksi
Masuk ke folder proyek API dari bagian 11, lalu install driver.
go get modernc.org/sqlite
Driver ini ditulis murni dalam Go, jadi tidak butuh compiler C. Sekarang buka koneksi di main.go.
package main
import (
"database/sql"
"log"
_ "modernc.org/sqlite"
)
var db *sql.DB
func main() {
var err error
db, err = sql.Open("sqlite", "toko.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
if err := db.Ping(); err != nil {
log.Fatal("gagal konek ke database:", err)
}
log.Println("database siap")
}
Ada dua hal yang perlu diperhatikan di sini. Pertama, import driver pakai underscore. Kita tidak memanggil fungsi apa pun dari package itu secara langsung. Import dengan underscore hanya menjalankan kode registrasinya, sehingga driver mendaftarkan diri ke database/sql dengan nama sqlite. Nama itulah yang kita pakai sebagai argumen pertama sql.Open.
Kedua, sql.Open tidak langsung membuat koneksi. Fungsi ini hanya menyiapkan objek *sql.DB dan memvalidasi argumennya. Koneksi sebenarnya baru dibuat saat dibutuhkan. Karena itu kita panggil db.Ping() untuk memaksa satu koneksi terbentuk. Kalau file database tidak bisa dibuat atau konfigurasi salah, kita tahu sejak awal, bukan saat request pertama masuk.
Membuat Tabel
SQLite akan membuat file toko.db otomatis saat pertama diakses. Tapi tabelnya harus kita buat sendiri. Untuk perintah SQL yang tidak mengembalikan baris data, pakai db.Exec.
func buatTabel() error {
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS produk (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nama TEXT,
harga REAL,
stok INTEGER
)`)
return err
}
Klausa IF NOT EXISTS membuat fungsi ini aman dipanggil berulang. Tabel hanya dibuat sekali, panggilan berikutnya tidak melakukan apa-apa. Kolom id memakai AUTOINCREMENT, jadi database yang mengurus penomoran. Anda tidak perlu lagi menghitung ID sendiri seperti di bagian 11. Panggil buatTabel() di main tepat setelah db.Ping() berhasil.
INSERT dan Bahaya SQL Injection
Menyimpan data juga pakai db.Exec. Perhatikan tanda tanya di dalam query.
hasil, err := db.Exec(
"INSERT INTO produk (nama, harga, stok) VALUES (?, ?, ?)",
"Kopi Arabika", 45000, 10,
)
if err != nil {
log.Fatal(err)
}
id, _ := hasil.LastInsertId()
log.Println("produk tersimpan dengan id", id)
Tanda tanya itu namanya placeholder. Nilai aslinya dikirim terpisah dari teks query, lalu database yang menggabungkannya dengan aman. Ini bukan sekadar gaya penulisan. Ini keharusan.
Bandingkan dengan cara yang salah: menggabungkan input user langsung ke string query.
// JANGAN PERNAH seperti ini
query := "SELECT * FROM produk WHERE nama = '" + input + "'"
Kelihatannya jalan. Sampai ada user iseng yang mengirim input berisi ' OR '1'='1. Query yang sampai ke database berubah menjadi SELECT * FROM produk WHERE nama = '' OR '1'='1'. Kondisi '1'='1' selalu benar, jadi semua baris ikut terambil. Dengan input yang lebih jahat, penyerang bisa menghapus tabel atau membaca data user lain. Teknik ini namanya SQL injection, dan sampai sekarang masih jadi salah satu celah keamanan paling umum di dunia. Dengan placeholder, input tadi diperlakukan murni sebagai teks biasa, bukan bagian dari perintah SQL. Serangannya mati sebelum sempat jalan.
Membaca Data: Query dan QueryRow
Untuk mengambil banyak baris, pakai db.Query. Hasilnya berupa *sql.Rows yang kita telusuri satu per satu.
func ambilSemuaProduk() ([]Produk, error) {
rows, err := db.Query("SELECT id, nama, harga, stok FROM produk")
if err != nil {
return nil, err
}
defer rows.Close()
var daftar []Produk
for rows.Next() {
var p Produk
if err := rows.Scan(&p.ID, &p.Nama, &p.Harga, &p.Stok); err != nil {
return nil, err
}
daftar = append(daftar, p)
}
return daftar, rows.Err()
}
Alurnya selalu sama. rows.Next() maju ke baris berikutnya dan mengembalikan false saat baris habis. rows.Scan menyalin nilai kolom ke variabel Anda, urutannya harus cocok dengan urutan kolom di SELECT. Dan defer rows.Close() wajib ada. Tanpa itu, koneksi database tertahan terus dan lama-lama aplikasi kehabisan koneksi. Terakhir, rows.Err() menangkap error yang mungkin terjadi di tengah iterasi.
Untuk mengambil tepat satu baris, ada jalur yang lebih pendek: QueryRow.
func ambilProdukByID(id int) (Produk, error) {
var p Produk
err := db.QueryRow(
"SELECT id, nama, harga, stok FROM produk WHERE id = ?", id,
).Scan(&p.ID, &p.Nama, &p.Harga, &p.Stok)
return p, err
}
Kalau tidak ada baris yang cocok, Scan mengembalikan error khusus bernama sql.ErrNoRows. Error ini bukan tanda ada yang rusak. Artinya cuma satu: datanya memang tidak ada. Nanti di handler, error inilah yang kita terjemahkan menjadi respons 404.
UPDATE dan DELETE
Keduanya memakai db.Exec, sama seperti INSERT. Yang menarik adalah cara mengecek apakah perintahnya benar-benar mengubah sesuatu.
hasil, err := db.Exec(
"UPDATE produk SET stok = ? WHERE id = ?", 25, 3,
)
if err != nil {
log.Fatal(err)
}
jumlah, _ := hasil.RowsAffected()
if jumlah == 0 {
log.Println("tidak ada produk dengan id itu")
}
UPDATE ke ID yang tidak ada itu bukan error di mata database. Query-nya valid, hanya saja tidak ada baris yang kena. Karena itu kita cek RowsAffected(). Nol artinya tidak ada yang berubah, dan di API biasanya itu diterjemahkan jadi 404. DELETE polanya persis sama.
hasil, err := db.Exec("DELETE FROM produk WHERE id = ?", 3)
Menyambungkan ke API Bagian 11
Sekarang bagian yang ditunggu. Kita ganti penyimpanan slice di API bagian 11 dengan database. Tiga fungsi tadi, ambilSemuaProduk, ambilProdukByID, dan satu lagi simpanProduk di bawah ini, jadi satu-satunya tempat kode SQL berada.
func simpanProduk(p Produk) (int64, error) {
hasil, err := db.Exec(
"INSERT INTO produk (nama, harga, stok) VALUES (?, ?, ?)",
p.Nama, p.Harga, p.Stok,
)
if err != nil {
return 0, err
}
return hasil.LastInsertId()
}
Memisahkan akses database ke fungsi sendiri seperti ini adalah kebiasaan yang layak dipelihara sejak awal. Handler jadi fokus mengurus HTTP: baca request, panggil fungsi data, tulis respons. Fungsi data fokus mengurus SQL. Kalau suatu hari pindah dari SQLite ke PostgreSQL, yang berubah hanya lapisan fungsi data, handler tidak tersentuh. Di dunia kerja pola ini punya nama sendiri, tapi intinya sesederhana itu: satu lapisan, satu tanggung jawab.
Berikut tiga handler yang berubah. Routing, struct Produk, dan sisanya tetap sama seperti bagian 11, jadi tidak saya ulang.
func handleDaftarProduk(w http.ResponseWriter, r *http.Request) {
daftar, err := ambilSemuaProduk()
if err != nil {
http.Error(w, "gagal membaca data", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(daftar)
}
func handleDetailProduk(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.Error(w, "id tidak valid", http.StatusBadRequest)
return
}
p, err := ambilProdukByID(id)
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "produk tidak ditemukan", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "gagal membaca data", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(p)
}
Perhatikan urutan pengecekan error. sql.ErrNoRows dicek dulu pakai errors.Is, materi error dari bagian sebelumnya, lalu diterjemahkan jadi 404. Error lain berarti ada masalah di sisi server, jadi 500.
func handleTambahProduk(w http.ResponseWriter, r *http.Request) {
var p Produk
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
http.Error(w, "body tidak valid", http.StatusBadRequest)
return
}
id, err := simpanProduk(p)
if err != nil {
http.Error(w, "gagal menyimpan data", http.StatusInternalServerError)
return
}
p.ID = int(id)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(p)
}
Ada bonus yang enak di refactor ini. Mutex dari bagian 11 boleh dihapus. Objek *sql.DB memang dirancang aman dipakai banyak goroutine sekaligus, lengkap dengan connection pool di dalamnya. Satu kerumitan hilang.
Satu catatan kecil untuk nanti. Setiap fungsi di atas punya versi ber-context: QueryContext, ExecContext, dan kawan-kawannya. Context berguna untuk membatalkan query yang kelamaan, misalnya saat client keburu menutup koneksi. Kita bahas layak-layaknya di bagian lanjutan, sekarang cukup tahu dulu bahwa versi itu ada.
Uji Coba: Momen Kemenangannya
Jalankan server, lalu tambah produk lewat curl.
go run .
curl -X POST http://localhost:8080/produk \
-H "Content-Type: application/json" \
-d '{"nama":"Teh Melati","harga":12000,"stok":30}'
Cek daftarnya.
curl http://localhost:8080/produk
Produk muncul. Sekarang bagian pentingnya. Matikan server dengan Ctrl+C. Di bagian 11, langkah ini menghapus semua data. Jalankan lagi go run ., lalu panggil endpoint daftar sekali lagi.
curl http://localhost:8080/produk
Datanya masih ada. Teh Melati tetap di sana, lengkap dengan ID-nya. Inilah bedanya API mainan dan API yang bisa dipakai sungguhan. Semua tersimpan di file toko.db, dan Anda bisa buka file itu kapan saja pakai tool SQLite mana pun.
Rangkuman dan Bagian Selanjutnya
Hari ini Anda belajar banyak hal penting. database/sql sebagai interface standar dan driver sebagai implementasinya. sql.Open yang ternyata tidak langsung konek, sehingga perlu Ping. Exec untuk perintah tulis, Query dan QueryRow untuk baca, Scan untuk menyalin hasil, RowsAffected untuk memastikan ada yang berubah. Plus satu aturan yang tidak boleh ditawar: selalu pakai placeholder, jangan pernah menyambung input user ke string SQL.
API Anda sekarang punya ingatan permanen. Tapi siapa pun masih bisa menambah dan menghapus produk seenaknya. Itu masalah berikutnya. Di bagian 13 kita bahas Middleware dan Auth Sederhana untuk API.
Kalau Anda sedang butuh aplikasi bisnis yang datanya rapi dan tidak hilang-hilangan, tim kami juga mengerjakan pengembangan sistem aplikasi dari desain database sampai API-nya.
Artikel Lainnya di Kategori Golang
Golang 31 Juli 2026
Belajar Golang dari Nol #5: Slice dan Map, Kumpulan Data di Go
Lanjutan seri Go pemula: slice, append, map, comma ok idiom, jebakan nil map, plus latihan keranjang belanja mini dengan pengecekan stok.
Baca Artikel
Golang 29 Juli 2026
Belajar Golang dari Nol #3: Function, Multiple Return, dan Error
Lanjutan seri Golang pemula: belajar function, multiple return, konvensi error, pola if err != nil, dan variadic lewat latihan kalkulator diskon.
Baca Artikel
Golang 3 Agustus 2026
Belajar Golang dari Nol #8: Package dan Go Module
Lanjutan seri Go pemula: pecah kode ke banyak file dengan package, pahami go.mod, aturan huruf besar exported, sampai import package pihak ketiga.
Baca ArtikelIngin Membaca Artikel Lainnya?
Temukan lebih banyak insight dan tips tentang teknologi dan bisnis digital.
Lihat Semua Artikel