Skip to main content

Code that compiles on the first try! CodeWithMpia wishes you very happy holidays.✨

Aller au contenu principal

Bases de données en Go : SQL, PostgreSQL, MongoDB et bonnes pratiques

Un guide complet et concret pour manipuler les bases de données en Go : se connecter, écrire des requêtes SQL, utiliser un ORM, gérer les transactions, et appliquer des patterns taillés pour la prod.

M
Mpia
12/28/2025 43 min
35 vues
0 comme.
#Go#Database#SQL
Bases de données en Go : SQL, PostgreSQL, MongoDB et bonnes pratiques

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 :

  1. database/sql : Interface standard (fournie par Go)

  2. Driver : Implémentation spécifique (package tiers)

  3. 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 :

  1. Query() pour plusieurs lignes

  2. defer rows.Close() immédiatement

  3. Boucle for rows.Next()

  4. Scan() pour chaque ligne

  5. 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 :

  1. Si les deux updates réussissent → Commit() applique les changements

  2. Si l'un échoue → Rollback() annule tout

  3. 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 :

Commentaires (0)

Laisser un commentaire

Aucun commentaire pour le moment. Soyez le premier à commenter !