Salut ! Si tu développes des applications backend en Go, tu vas forcément interagir avec des bases de données. Et devine quoi ? Go rend ça incroyablement simple et performant. Pas de magie noire, pas de configuration complexe - juste du code clair et des performances au rendez-vous.
Dans cet article, je vais t'expliquer comment travailler avec des bases de données en Go, depuis les bases jusqu'aux patterns de production utilisés dans les vraies applications. On va explorer le package database/sql, voir comment se connecter à PostgreSQL et MySQL, découvrir quand utiliser (ou pas) un ORM, et maîtriser les patterns essentiels comme les connection pools et les transactions.
À la fin, tu sauras gérer les bases de données en Go comme un pro.
Prêt à plonger dans le monde de la persistance ? Let's go ! 🚀
Pourquoi les bases de données en Go sont géniales
Avant de coder, comprenons pourquoi Go brille particulièrement pour les interactions avec les bases de données.
Performance native
Go compile en code machine natif. Pas d'interpréteur, pas de JVM. Résultat ? Tes requêtes s'exécutent à une vitesse proche du C, bien plus rapide que Node.js ou Python.
Les benchmarks montrent que Go peut gérer 2 à 3 fois plus de requêtes par seconde que Node.js pour des opérations de base de données typiques. C'est énorme quand tu as des milliers d'utilisateurs simultanés.
Connection pooling intégré
Le package database/sql gère automatiquement un pool de connexions. Tu n'as pas à te soucier d'ouvrir et fermer des connexions manuellement - Go s'occupe de tout. C'est crucial pour les performances et la scalabilité.
Concurrence avec goroutines
Imagine : tu dois faire 100 requêtes pour charger des données de profils utilisateurs. Avec Go, tu lances 100 goroutines qui s'exécutent concurrentiellement. C'est simple, performant, et le code reste lisible.
// Charger 100 utilisateurs en parallèle
for i := 1; i <= 100; i++ {
go func(id int) {
user, _ := getUser(id)
// Traiter l'utilisateur
}(i)
}
Dans d'autres langages, faire ça proprement est beaucoup plus complexe.
Type safety
Go est typé statiquement. Tes erreurs de types sont détectées à la compilation, pas au runtime. Fini les bugs où tu essaies d'insérer une string dans une colonne int parce que tu as oublié de convertir.
Le package database/sql : la fondation
Go fournit database/sql, un package standard qui définit une interface générique pour travailler avec les bases de données SQL. Tu écris ton code une fois, et il fonctionne avec MySQL, PostgreSQL, SQLite, etc.
Comment ça fonctionne
database/sql est une abstraction. Il ne parle pas directement aux bases de données. Pour ça, tu as besoin d'un driver spécifique à ta base de données.
Le principe :
-
database/sql: Interface standard (fournie par Go) -
Driver : Implémentation spécifique (package tiers)
-
Ta base de données : PostgreSQL, MySQL, etc.
C'est comme JDBC en Java ou PDO en PHP - une couche d'abstraction qui rend ton code portable.
Drivers populaires
-
PostgreSQL :
github.com/lib/pq -
MySQL :
github.com/go-sql-driver/mysql -
SQLite :
github.com/mattn/go-sqlite3 -
SQL Server :
github.com/denisenkom/go-mssqldb
On va se concentrer sur PostgreSQL dans cet article, mais les concepts s'appliquent à toutes les bases SQL.
Se connecter à PostgreSQL
Commençons par le commencement : établir une connexion.
Installation du driver
go get github.com/lib/pq
Connexion basique
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/lib/pq" // Import pour side-effects (enregistre le driver)
)
func main() {
// Format: postgres://user:password@host:port/database?options
connStr := "postgres://username:password@localhost:5432/mydb?sslmode=disable"
// Ouvrir la connexion
db, err := sql.Open("postgres", connStr)
if err != nil {
log.Fatal(err)
}
defer db.Close() // Fermer à la fin
// Vérifier que la connexion fonctionne
err = db.Ping()
if err != nil {
log.Fatal(err)
}
fmt.Println("Connecté à PostgreSQL!")
}
Important : sql.Open() ne crée pas vraiment de connexion. Il initialise juste le pool. C'est Ping() qui établit réellement une connexion pour vérifier que tout fonctionne.
Configuration du connection pool
Par défaut, Go configure un pool de connexions intelligent. Mais tu peux le personnaliser :
db, err := sql.Open("postgres", connStr)
if err != nil {
log.Fatal(err)
}
// Nombre maximum de connexions ouvertes
db.SetMaxOpenConns(25)
// Nombre maximum de connexions idle (en attente)
db.SetMaxIdleConns(5)
// Durée de vie maximale d'une connexion
db.SetConnMaxLifetime(5 * time.Minute)
Pourquoi c'est important ?
-
Trop de connexions ouvertes → tu satures ta base de données
-
Trop peu → goulot d'étranglement, performances dégradées
-
Connexions qui restent ouvertes trop longtemps → problèmes réseau
Les valeurs par défaut sont bonnes pour démarrer, mais ajuste selon ta charge.
CRUD : Les opérations de base
Maintenant qu'on est connecté, créons, lisons, modifions et supprimons des données.
Créer la table
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Définir la struct
type User struct {
ID int
Username string
Email string
CreatedAt time.Time
}
INSERT : Créer des données
func createUser(db *sql.DB, username, email string) (int, error) {
var id int
query := `
INSERT INTO users (username, email)
VALUES ($1, $2)
RETURNING id
`
err := db.QueryRow(query, username, email).Scan(&id)
if err != nil {
return 0, err
}
return id, nil
}
// Utilisation
id, err := createUser(db, "alice", "alice@example.com")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Utilisateur créé avec ID: %d\n", id)
Points clés :
-
$1,$2: Placeholders PostgreSQL (évite les injections SQL) -
RETURNING id: PostgreSQL retourne l'ID auto-généré -
QueryRow(): Pour une seule ligne de résultat -
Scan(): Lit le résultat dans nos variables
MySQL ? Utilise ? au lieu de $1, et récupère l'ID avec result.LastInsertId().
SELECT : Lire une ligne
func getUserByID(db *sql.DB, id int) (*User, error) {
user := &User{}
query := `
SELECT id, username, email, created_at
FROM users
WHERE id = $1
`
err := db.QueryRow(query, id).Scan(
&user.ID,
&user.Username,
&user.Email,
&user.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("utilisateur non trouvé")
}
if err != nil {
return nil, err
}
return user, nil
}
Gestion de l'absence de résultat : sql.ErrNoRows est retourné quand aucune ligne ne correspond. C'est important de gérer ce cas spécifiquement.
SELECT : Lire plusieurs lignes
func getAllUsers(db *sql.DB) ([]User, error) {
query := `SELECT id, username, email, created_at FROM users`
rows, err := db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close() // IMPORTANT : toujours fermer rows
users := []User{}
for rows.Next() {
var user User
err := rows.Scan(
&user.ID,
&user.Username,
&user.Email,
&user.CreatedAt,
)
if err != nil {
return nil, err
}
users = append(users, user)
}
// Vérifier les erreurs après la boucle
if err = rows.Err(); err != nil {
return nil, err
}
return users, nil
}
Pattern à retenir :
-
Query()pour plusieurs lignes -
defer rows.Close()immédiatement -
Boucle
for rows.Next() -
Scan()pour chaque ligne -
Vérifier
rows.Err()après
UPDATE : Modifier des données
func updateUserEmail(db *sql.DB, id int, newEmail string) error {
query := `UPDATE users SET email = $1 WHERE id = $2`
result, err := db.Exec(query, newEmail, id)
if err != nil {
return err
}
// Vérifier combien de lignes ont été affectées
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return fmt.Errorf("aucun utilisateur avec ID %d", id)
}
fmt.Printf("%d ligne(s) modifiée(s)\n", rowsAffected)
return nil
}
Exec() est utilisé pour les requêtes qui ne retournent pas de lignes (INSERT, UPDATE, DELETE).
DELETE : Supprimer des données
func deleteUser(db *sql.DB, id int) error {
query := `DELETE FROM users WHERE id = $1`
result, err := db.Exec(query, id)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return fmt.Errorf("utilisateur %d non trouvé", id)
}
return nil
}
Requêtes préparées : performance et sécurité
Les requêtes préparées sont compilées une fois et réutilisées. C'est plus rapide et plus sûr.
Pourquoi utiliser des prepared statements ?
Sécurité : Protection automatique contre les injections SQL.
Performance : Si tu exécutes la même requête plusieurs fois avec des paramètres différents, la base de données n'a pas à la reparser à chaque fois.
func batchCreateUsers(db *sql.DB, users []User) error {
// Préparer la requête
stmt, err := db.Prepare(`INSERT INTO users (username, email) VALUES ($1, $2)`)
if err != nil {
return err
}
defer stmt.Close()
// Exécuter pour chaque utilisateur
for _, user := range users {
_, err := stmt.Exec(user.Username, user.Email)
if err != nil {
return err
}
}
return nil
}
Important : Toujours defer stmt.Close() après avoir préparé une requête.
Transactions : garantir la cohérence
Les transactions garantissent que plusieurs opérations réussissent ensemble ou échouent ensemble. C'est crucial pour la cohérence des données.
Transaction basique
func transfererArgent(db *sql.DB, fromUserID, toUserID int, montant float64) error {
// Démarrer une transaction
tx, err := db.Begin()
if err != nil {
return err
}
// En cas d'erreur, rollback
defer func() {
if err != nil {
tx.Rollback()
}
}()
// Débiter le compte source
_, err = tx.Exec(
`UPDATE accounts SET balance = balance - $1 WHERE user_id = $2`,
montant, fromUserID,
)
if err != nil {
return err
}
// Créditer le compte destination
_, err = tx.Exec(
`UPDATE accounts SET balance = balance + $1 WHERE user_id = $2`,
montant, toUserID,
)
if err != nil {
return err
}
// Tout a réussi, commit
return tx.Commit()
}
Ce qui se passe :
-
Si les deux updates réussissent →
Commit()applique les changements -
Si l'un échoue →
Rollback()annule tout -
Si on oublie de commit →
Rollback()automatique à la fermeture de tx
Pattern avec defer pour robustesse
func updateUserTransaction(db *sql.DB, user User) error {
tx, err := db.Begin()
if err != nil {
return err
}
// Pattern robuste avec defer
defer func() {
if p := recover(); p != nil {
tx.Rollback()
panic(p) // Re-throw la panic
} else if err != nil {
tx.Rollback()
} else {
err = tx.Commit()
}
}()
// Opérations de la transaction
_, err = tx.Exec(`UPDATE users SET username = $1 WHERE id = $2`, user.Username, user.ID)
if err != nil {
return err
}
_, err = tx.Exec(`INSERT INTO audit_log (action, user_id) VALUES ($1, $2)`, "update", user.ID)
if err != nil {
return err
}
return nil
}
Ce pattern gère automatiquement commit/rollback selon le résultat.
Gérer les valeurs NULL
SQL permet les valeurs NULL, mais Go ne les aime pas. Comment gérer ça proprement ?
Le problème
type User struct {
ID int
Username string
Bio string // Et si bio est NULL dans la DB ?
}
// Si bio est NULL, Scan() va échouer !
Solution 1 : Types sql.Null*
type User struct {
ID int
Username string
Bio sql.NullString // Peut être NULL
Age sql.NullInt64
}
func getUser(db *sql.DB, id int) (*User, error) {
user := &User{}
err := db.QueryRow(`SELECT id, username, bio, age FROM users WHERE id = $1`, id).
Scan(&user.ID, &user.Username, &user.Bio, &user.Age)
if err != nil {
return nil, err
}
return user, nil
}
// Utilisation
if user.Bio.Valid {
fmt.Println("Bio:", user.Bio.String)
} else {
fmt.Println("Pas de bio")
}
Les types sql.Null* ont deux champs :
-
Valid: bool indiquant si la valeur existe -
String/Int64/Float64/etc.: la valeur réelle
Solution 2 : Pointeurs
type User struct {
ID int
Username string
Bio *string // nil si NULL
Age *int
}
// NULL devient nil en Go
if user.Bio != nil {
fmt.Println("Bio:", *user.Bio)
}
Les pointeurs sont plus idiomatiques en Go, mais sql.Null* est plus explicite.
Patterns avancés : Repository pattern
Pour de vraies applications, tu veux séparer la logique de base de données du reste du code.
Structure du repository
// Repository interface
type UserRepository interface {
Create(user *User) error
GetByID(id int) (*User, error)
GetAll() ([]User, error)
Update(user *User) error
Delete(id int) error
}
// Implémentation PostgreSQL
type PostgresUserRepository struct {
db *sql.DB
}
func NewPostgresUserRepository(db *sql.DB) *PostgresUserRepository {
return &PostgresUserRepository{db: db}
}
func (r *PostgresUserRepository) Create(user *User) error {
query := `
INSERT INTO users (username, email)
VALUES ($1, $2)
RETURNING id, created_at
`
err := r.db.QueryRow(query, user.Username, user.Email).
Scan(&user.ID, &user.CreatedAt)
return err
}
func (r *PostgresUserRepository) GetByID(id int) (*User, error) {
user := &User{}
query := `SELECT id, username, email, created_at FROM users WHERE id = $1`
err := r.db.QueryRow(query, id).Scan(
&user.ID,
&user.Username,
&user.Email,
&user.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("utilisateur non trouvé")
}
if err != nil {
return nil, err
}
return user, nil
}
// ... autres méthodes
Utilisation du repository
func main() {
db, _ := sql.Open("postgres", connStr)
defer db.Close()
// Créer le repository
userRepo := NewPostgresUserRepository(db)
// Utiliser le repository
user := &User{
Username: "alice",
Email: "alice@example.com",
}
err := userRepo.Create(user)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Utilisateur créé: %+v\n", user)
}
Avantages du pattern :
-
Code testable (tu peux mocker le repository)
-
Logique métier séparée de la DB
-
Facile de changer de base de données
ORM : sqlx et GORM
Parfois, écrire du SQL raw devient répétitif. C'est là qu'interviennent les ORMs.
sqlx : extension de database/sql
sqlx étend database/sql avec des fonctionnalités pratiques sans cacher le SQL.
go get github.com/jmoiron/sqlx
import "github.com/jmoiron/sqlx"
type User struct {
ID int `db:"id"`
Username string `db:"username"`
Email string `db:"email"`
CreatedAt time.Time `db:"created_at"`
}
func main() {
db, _ := sqlx.Connect("postgres", connStr)
defer db.Close()
// Get : lire une ligne directement dans une struct
user := User{}
err := db.Get(&user, `SELECT * FROM users WHERE id = $1`, 1)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%+v\n", user)
// Select : lire plusieurs lignes
users := []User{}
err = db.Select(&users, `SELECT * FROM users`)
if err != nil {
log.Fatal(err)
}
// NamedExec : requêtes avec struct
newUser := User{Username: "bob", Email: "bob@example.com"}
_, err = db.NamedExec(
`INSERT INTO users (username, email) VALUES (:username, :email)`,
newUser,
)
}
sqlx rend le code plus concis sans sacrifier le contrôle.
GORM : ORM complet
GORM est un ORM full-featured comme Hibernate ou Django ORM.
go get gorm.io/gorm
go get gorm.io/driver/postgres
import (
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
type User struct {
ID uint `gorm:"primaryKey"`
Username string `gorm:"uniqueIndex"`
Email string `gorm:"uniqueIndex"`
CreatedAt time.Time
}
func main() {
dsn := "host=localhost user=username password=password dbname=mydb sslmode=disable"
db, _ := gorm.Open(postgres.Open(dsn), &gorm.Config{})
// Auto-migration (crée/met à jour la table)
db.AutoMigrate(&User{})
// Create
user := User{Username: "alice", Email: "alice@example.com"}
db.Create(&user)
// Read
var foundUser User
db.First(&foundUser, 1) // Par ID
db.Where("username = ?", "alice").First(&foundUser)
// Update
db.Model(&foundUser).Update("email", "newemail@example.com")
// Delete
db.Delete(&foundUser)
// Relations
type Post struct {
ID uint
Title string
UserID uint
User User `gorm:"foreignKey:UserID"`
}
// Eager loading
var post Post
db.Preload("User").First(&post)
}
ORM ou pas ORM ?
Utilise database/sql ou sqlx si :
-
Tu veux un contrôle total sur tes requêtes
-
Performance critique (requêtes complexes optimisées)
-
Tu es à l'aise avec SQL
Utilise GORM si :
-
Productivité maximale
-
CRUD simples
-
Tu n'aimes pas écrire du SQL
-
Prototypage rapide
Mon conseil : commence avec database/sql pour comprendre, puis adopte sqlx pour plus de confort. GORM quand tu veux vraiment gagner du temps.
MongoDB : bases de données NoSQL
Go fonctionne aussi très bien avec MongoDB.
go get go.mongodb.org/mongo-driver/mongo
import (
"context"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
type User struct {
ID string `bson:"_id,omitempty"`
Username string `bson:"username"`
Email string `bson:"email"`
}
func main() {
// Connexion
client, _ := mongo.Connect(
context.TODO(),
options.Client().ApplyURI("mongodb://localhost:27017"),
)
defer client.Disconnect(context.TODO())
collection := client.Database("mydb").Collection("users")
// Insert
user := User{Username: "alice", Email: "alice@example.com"}
result, _ := collection.InsertOne(context.TODO(), user)
fmt.Println("ID:", result.InsertedID)
// Find One
var foundUser User
filter := bson.M{"username": "alice"}
collection.FindOne(context.TODO(), filter).Decode(&foundUser)
// Find Many
cursor, _ := collection.Find(context.TODO(), bson.M{})
var users []User
cursor.All(context.TODO(), &users)
// Update
update := bson.M{"$set": bson.M{"email": "newemail@example.com"}}
collection.UpdateOne(context.TODO(), filter, update)
// Delete
collection.DeleteOne(context.TODO(), filter)
}
MongoDB est idéal pour les données non structurées ou semi-structurées.
Migrations : gérer l'évolution du schéma
En production, ton schéma de base de données évolue. Les migrations gèrent ça proprement.
golang-migrate
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
# Créer une migration
migrate create -ext sql -dir migrations -seq create_users_table
# Génère :
# migrations/000001_create_users_table.up.sql
# migrations/000001_create_users_table.down.sql
Up migration (000001_create_users_table.up.sql) :
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Down migration (000001_create_users_table.down.sql) :
DROP TABLE IF EXISTS users;
Exécuter les migrations :
migrate -database "postgres://user:pass@localhost:5432/mydb?sslmode=disable" -path migrations up
En Go :
import (
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
)
func runMigrations(dbURL string) error {
m, err := migrate.New(
"file://migrations",
dbURL,
)
if err != nil {
return err
}
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
return err
}
return nil
}
Bonnes pratiques de production
1. Connection pooling configuré
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
2. Toujours utiliser des requêtes préparées
// ✗ Mauvais : injection SQL possible
query := fmt.Sprintf("SELECT * FROM users WHERE id = %d", userID)
// ✓ Bon : protégé
db.QueryRow("SELECT * FROM users WHERE id = $1", userID)
3. Gérer les erreurs proprement
user, err := getUserByID(db, 1)
if err == sql.ErrNoRows {
// Cas spécial : pas trouvé
return nil, fmt.Errorf("utilisateur non trouvé")
}
if err != nil {
// Erreur réelle
return nil, err
}
4. Context pour timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, "SELECT * FROM users")
5. Logs structurés
import "log/slog"
if err != nil {
slog.Error("échec requête DB",
"error", err,
"query", query,
"user_id", userID,
)
}
6. Tests avec base de données de test
func setupTestDB(t *testing.T) *sql.DB {
db, err := sql.Open("postgres", "postgres://localhost/test_db")
if err != nil {
t.Fatal(err)
}
// Nettoyer après le test
t.Cleanup(func() {
db.Exec("TRUNCATE users")
db.Close()
})
return db
}
Conclusion
Go rend le travail avec les bases de données simple, performant et sûr. Le package database/sql fournit une base solide, et l'écosystème offre des outils comme sqlx et GORM pour plus de productivité.
Points clés à retenir :
-
database/sql : Interface standard, connection pooling intégré
-
Prepared statements : Sécurité et performance
-
Transactions : Cohérence des données
-
Repository pattern : Séparation des responsabilités
-
sqlx : Extension pratique de database/sql
-
GORM : ORM complet pour productivité maximale
-
Migrations : Gérer l'évolution du schéma
Quand utiliser quoi :
-
Projet simple →
database/sql -
Besoin de confort →
sqlx -
Productivité max →
GORM -
NoSQL → MongoDB driver officiel
Go brille particulièrement pour les applications backend avec forte charge de base de données. Combine ça avec les goroutines, et tu as une machine de guerre pour les APIs haute performance.
Ressources pour aller plus loin :
Tu as des questions sur les bases de données en Go ? Partage-les dans les commentaires !
Articles connexes :

Laisser un commentaire