Een backend zonder database is als een winkel zonder voorraad. Database toegang in Go regel je met het standaard database/sql pakket, een elegante abstractielaag die met vrijwel elke relationele database overweg kan.
In dit artikel leer je hoe je verbinding maakt met een database, queries uitvoert, resultaten verwerkt en transacties beheert. We behandelen ook connectie pooling, prepared statements en veelgemaakte fouten die beginners tegenkomen.
Heb je de basis van Go nog niet onder de knie? Begin dan eerst met installatie en je eerste Go programma en lees daarna over variabelen en types in Go.
Het database/sql pakket in Go
Go kiest bewust voor een minimale standaardbibliotheek wat databases betreft. Het database/sql pakket definieert alleen interfaces, de daadwerkelijke communicatie met een specifieke database regelt een driver.
Dit scheidingsprincipe betekent dat je code grotendeels hetzelfde blijft of je nu met PostgreSQL, MySQL of SQLite werkt. Je swapt alleen de driver import.
Populaire drivers zijn:
- PostgreSQL:
github.com/jackc/pgx/v5ofgithub.com/lib/pq - MySQL:
github.com/go-sql-driver/mysql - SQLite:
modernc.org/sqlite(pure Go) ofgithub.com/mattn/go-sqlite3
Driver installeren
Een driver voeg je toe zoals elke andere Go dependency:
go get github.com/jackc/pgx/v5/stdlib
De stdlib subpackage zorgt dat pgx werkt via de database/sql interface. Dit is ideaal als je bekende patterns wilt hergebruiken.
Verbinding maken met een database
Verbinding maken doe je met sql.Open. Let op: ondanks de naam opent deze functie niet direct een connectie, het initialiseert een connectie pool.
package main
import (
"database/sql"
"log"
_ "github.com/jackc/pgx/v5/stdlib"
)
func main() {
dsn := "postgres://user:password@localhost:5432/mydb?sslmode=disable"
db, err := sql.Open("pgx", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
if err := db.Ping(); err != nil {
log.Fatal("database onbereikbaar:", err)
}
log.Println("verbonden met database")
}
De underscore import (_ "github.com/...") zorgt dat de driver zich registreert zonder dat je expliciet types gebruikt. Met db.Ping() verifieer je of de database écht bereikbaar is.
Connectie pooling configureren
Het *sql.DB object is geen enkele connectie, het is een pool. Go beheert automatisch hoeveel connecties open staan, maar je kunt dit fine-tunen:
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
Voor productie-applicaties is dit cruciaal. Te veel connecties overbelasten je database, te weinig veroorzaken wachttijden onder hoge belasting.
Queries uitvoeren
Er zijn twee hoofdmethodes: Query voor SELECTs en Exec voor wijzigingen. Daarnaast bestaat QueryRow voor queries die één rij retourneren.
Enkele rij ophalen met QueryRow
type User struct {
ID int
Name string
Email string
}
func getUser(db *sql.DB, id int) (*User, error) {
var u User
err := db.QueryRow(
"SELECT id, name, email FROM users WHERE id = $1",
id,
).Scan(&u.ID, &u.Name, &u.Email)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &u, nil
}
De $1 placeholder is PostgreSQL-specifiek. MySQL en SQLite gebruiken ?. Parameters binden zo voorkomt SQL injection, nooit query strings concateneren met user input.
Meerdere rijen met Query
func getUsers(db *sql.DB) ([]User, error) {
rows, err := db.Query("SELECT id, name, email 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); err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err()
}
Drie details om te onthouden:
defer rows.Close()voorkomt dat connecties blijven hangen.rows.Next()itereert door het resultaat.rows.Err()controleert of de iteratie zelf fouten gaf.
Zie ook functions en error handling in Go voor meer over idiomatische foutafhandeling.
Data wijzigen met Exec
Voor INSERT, UPDATE en DELETE gebruik je Exec:
func createUser(db *sql.DB, name, email string) (int64, error) {
var id int64
err := db.QueryRow(
"INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id",
name, email,
).Scan(&id)
return id, err
}
func deleteUser(db *sql.DB, id int) (int64, error) {
result, err := db.Exec("DELETE FROM users WHERE id = $1", id)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
Bij PostgreSQL haal je de gegenereerde ID op met RETURNING. MySQL biedt result.LastInsertId() na een Exec.
Werken met context
Productiecode moet cancelbaar zijn. Contexten geven je controle over timeouts en annuleringen, essentieel als je dit vanuit HTTP handlers aanroept.
func getUserWithContext(ctx context.Context, db *sql.DB, id int) (*User, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
var u User
err := db.QueryRowContext(ctx,
"SELECT id, name, email FROM users WHERE id = $1", id,
).Scan(&u.ID, &u.Name, &u.Email)
return &u, err
}
Elke query-methode heeft een Context-variant: QueryContext, QueryRowContext, ExecContext. Gebruik ze altijd in server-code. Koppel dit aan je HTTP servers in Go voor robuuste endpoints.
Transacties
Een transactie groepeert meerdere queries tot één atomaire operatie. Alles slaagt, of alles wordt teruggedraaid.
func transferFunds(db *sql.DB, from, to int, amount int) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.Exec(
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
amount, from,
)
if err != nil {
return err
}
_, err = tx.Exec(
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
amount, to,
)
if err != nil {
return err
}
return tx.Commit()
}
De truc met defer tx.Rollback() is handig: als Commit() slaagt, is de rollback een no-op. Faalt iets onderweg, dan rolt Go automatisch terug.
Prepared statements
Prepared statements compileren een query één keer en voeren hem meerdere keren uit met verschillende parameters. Voor hot paths is dit een flinke winst.
stmt, err := db.Prepare("INSERT INTO logs (message, level) VALUES ($1, $2)")
if err != nil {
return err
}
defer stmt.Close()
for _, entry := range logEntries {
if _, err := stmt.Exec(entry.Message, entry.Level); err != nil {
return err
}
}
Merk op dat veel moderne drivers (zoals pgx) queries automatisch cachen, waardoor expliciete prepared statements minder vaak nodig zijn. Meet voor je optimaliseert.
NULL waarden afhandelen
Databases kennen NULL, Go kent alleen zero values. Voor kolommen die NULL kunnen bevatten gebruik je sql.NullString, sql.NullInt64, etc.
type Profile struct {
ID int
Name string
Bio sql.NullString
}
var p Profile
err := db.QueryRow("SELECT id, name, bio FROM profiles WHERE id = $1", 1).
Scan(&p.ID, &p.Name, &p.Bio)
if p.Bio.Valid {
fmt.Println("Bio:", p.Bio.String)
} else {
fmt.Println("Geen bio ingesteld")
}
Alternatief: gebruik pointers (*string). Is het pointer nil, dan was de kolom NULL. Dit speelt ook mooi samen met JSON handling in Go, een nil pointer serialiseert als null.
Concurrency en database toegang
Het *sql.DB object is thread-safe. Je kunt het veilig delen tussen goroutines. Sterker nog, de pool is juist ontworpen voor gelijktijdig gebruik.
var wg sync.WaitGroup
for _, id := range userIDs {
wg.Add(1)
go func(id int) {
defer wg.Done()
user, err := getUser(db, id)
if err != nil {
log.Println(err)
return
}
fmt.Println(user)
}(id)
}
wg.Wait()
Combineer dit met channels voor complexere pipelines waar database queries en processing door elkaar heen lopen.
Veelgemaakte fouten
Rows niet sluiten: vergeet je defer rows.Close(), dan lek je connecties tot de pool leeg is.
SQL concateneren: nooit "WHERE id = " + userInput. Gebruik altijd placeholders.
sql.Open in elke functie: maak *sql.DB één keer aan bij startup en deel het. Het is geen open connectie maar een pool.
Ontbrekende rows.Err(): na je loop kan rows.Next() gestopt zijn door een fout. Check altijd rows.Err().
Geen context in servers: zonder timeout kan een trage query je hele service gijzelen.
Wanneer overstappen op een ORM?
Voor kleine tot middelgrote applicaties is database/sql met wat helpers genoeg. Wordt je query-laag complex, dan zijn er alternatieven:
- sqlx, lichte uitbreiding met
StructScanvoor minder boilerplate - sqlc, genereert type-safe Go code uit SQL queries
- GORM, volledige ORM met relaties, hooks en migraties
De Go community neigt naar het laag houden van de abstractie. Zie ook waarom Go populair is voor backends, expliciete code boven magie.
Veelgestelde vragen
Welk pakket gebruik je voor database toegang in Go?
Het standaard database/sql pakket uit de standaardbibliotheek biedt een generieke interface voor SQL databases. Je combineert dit met een driver zoals pgx voor PostgreSQL of go-sql-driver/mysql voor MySQL.
Wat is het verschil tussen Query en Exec in Go?
Query gebruik je voor SELECT statements die rijen teruggeven. Exec gebruik je voor INSERT, UPDATE en DELETE waarbij je alleen het aantal geraakte rijen of een laatste insert ID nodig hebt.
Moet ik prepared statements gebruiken in Go?
Ja, voor queries die je vaker uitvoert of parameters bevatten. Prepared statements voorkomen SQL injection en kunnen sneller zijn doordat de database de query maar één keer parseert. Veel moderne drivers cachen dit overigens automatisch.
Hoe voorkom ik connectie lekken in Go?
Sluit altijd rows met defer rows.Close() na een Query en gebruik context met timeout. Configureer ook SetMaxOpenConns en SetConnMaxLifetime op je *sql.DB object om resources te beperken.
Kan ik een ORM gebruiken in plaats van database/sql?
Ja, bibliotheken zoals GORM, sqlx of sqlc zijn populair in de Go community. Veel Go developers geven echter de voorkeur aan het standaardpakket of lichte wrappers voor meer controle over queries en performance.
Conclusie
Database toegang in Go voelt in het begin wat low-level, maar die expliciete aanpak beloont je met voorspelbare code en uitstekende performance. Het database/sql pakket geeft je alle bouwstenen: connectie pooling, prepared statements, transacties en context support.
Begin met de standaardbibliotheek en voeg pas een ORM toe als je echt herhaling of complexiteit ziet. Combineer wat je hier geleerd hebt met structs en interfaces om een schone data-laag te bouwen waar de rest van je applicatie op steunt.