MySQL é o banco de dados relacional mais usado no mundo, e MariaDB é seu fork comunitário — binário e protocolo compatíveis, o mesmo driver Go funciona para ambos sem alteração. Juntos dominam o mercado de aplicações web, especialmente no ecossistema PHP/WordPress, mas são igualmente relevantes em backends Go.
O driver oficial da comunidade é o go-sql-driver/mysql — maduro, amplamente testado e com suporte a todas as funcionalidades modernas do MySQL 5.7+ e MariaDB 10.3+.
Configuração do ambiente
# Instalar o driver
go get github.com/go-sql-driver/mysql
# Subir MySQL com Docker para desenvolvimento
docker run --name mysql-dev \
-e MYSQL_ROOT_PASSWORD=senha \
-e MYSQL_DATABASE=app \
-e MYSQL_USER=usuario \
-e MYSQL_PASSWORD=senha \
-p 3306:3306 \
-d mysql:8.0
# MariaDB é idêntico
docker run --name mariadb-dev \
-e MARIADB_ROOT_PASSWORD=senha \
-e MARIADB_DATABASE=app \
-e MARIADB_USER=usuario \
-e MARIADB_PASSWORD=senha \
-p 3306:3306 \
-d mariadb:11
Estrutura do banco de dados
CREATE DATABASE IF NOT EXISTS app
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE app;
CREATE TABLE categorias (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
nome VARCHAR(100) NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE,
criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE produtos (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
categoria_id INT UNSIGNED NOT NULL,
nome VARCHAR(200) NOT NULL,
descricao TEXT,
preco DECIMAL(10, 2) NOT NULL,
estoque INT NOT NULL DEFAULT 0,
ativo BOOLEAN NOT NULL DEFAULT TRUE,
criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
atualizado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (categoria_id) REFERENCES categorias(id)
);
CREATE TABLE pedidos (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
cliente_nome VARCHAR(200) NOT NULL,
total DECIMAL(10, 2) NOT NULL DEFAULT 0,
status ENUM('pendente','confirmado','enviado','entregue','cancelado')
NOT NULL DEFAULT 'pendente',
criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE itens_pedido (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
pedido_id INT UNSIGNED NOT NULL,
produto_id INT UNSIGNED NOT NULL,
quantidade INT NOT NULL,
preco_unit DECIMAL(10, 2) NOT NULL,
FOREIGN KEY (pedido_id) REFERENCES pedidos(id),
FOREIGN KEY (produto_id) REFERENCES produtos(id)
);
Conexão e configuração do DSN
package main
import (
"context"
"database/sql"
"fmt"
"log"
"time"
"github.com/go-sql-driver/mysql"
)
func conectarMySQL() (*sql.DB, error) {
// Usando a struct Config do driver para configuração tipada
cfg := mysql.Config{
User: "usuario",
Passwd: "senha",
Net: "tcp",
Addr: "localhost:3306",
DBName: "app",
ParseTime: true, // converte DATETIME → time.Time
Loc: time.UTC,
Collation: "utf8mb4_unicode_ci",
MultiStatements: false, // segurança: desabilita múltiplos statements
InterpolateParams: false, // segurança: sempre usar placeholders
Timeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
AllowNativePasswords: true,
}
db, err := sql.Open("mysql", cfg.FormatDSN())
if err != nil {
return nil, fmt.Errorf("abrir conexão: %w", err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(5 * time.Minute)
db.SetConnMaxIdleTime(2 * time.Minute)
ctx, cancelar := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelar()
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("verificar conexão: %w", err)
}
log.Println("MySQL conectado com sucesso")
return db, nil
}
O parâmetro ParseTime=true é essencial — sem ele, campos DATETIME e TIMESTAMP chegam como []byte em vez de time.Time, exigindo conversão manual.
Tipos Go ↔ MySQL
| Tipo MySQL | Tipo Go recomendado |
|---|---|
INT, BIGINT |
int, int64 |
INT UNSIGNED |
uint, uint64 |
DECIMAL, NUMERIC |
string ou float64 |
FLOAT, DOUBLE |
float64 |
VARCHAR, TEXT |
string |
BOOLEAN, TINYINT(1) |
bool |
DATETIME, TIMESTAMP |
time.Time (com ParseTime=true) |
DATE |
time.Time |
JSON |
[]byte ou struct com json.Unmarshal |
NULL em qualquer tipo |
sql.NullString, sql.NullInt64, etc. |
Valores NULL com sql.Null*
type Produto struct {
ID uint
CategoriaID uint
Nome string
Descricao sql.NullString // pode ser NULL no banco
Preco float64
Estoque int
Ativo bool
CriadoEm time.Time
AtualizadoEm time.Time
}
func buscarProduto(ctx context.Context, db *sql.DB, id uint) (*Produto, error) {
query := `
SELECT id, categoria_id, nome, descricao, preco, estoque, ativo,
criado_em, atualizado_em
FROM produtos
WHERE id = ?
`
var p Produto
err := db.QueryRowContext(ctx, query, id).Scan(
&p.ID,
&p.CategoriaID,
&p.Nome,
&p.Descricao, // sql.NullString lida com NULL automaticamente
&p.Preco,
&p.Estoque,
&p.Ativo,
&p.CriadoEm,
&p.AtualizadoEm,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("produto %d não encontrado", id)
}
if err != nil {
return nil, fmt.Errorf("buscarProduto: %w", err)
}
return &p, nil
}
func exibirDescricao(p *Produto) {
if p.Descricao.Valid {
fmt.Println("Descrição:", p.Descricao.String)
} else {
fmt.Println("Descrição: (não informada)")
}
}
Repositório completo de produtos
package main
import (
"context"
"database/sql"
"fmt"
"time"
)
type ProdutoInput struct {
CategoriaID uint
Nome string
Descricao string
Preco float64
Estoque int
}
type RepoProdutosMySQL struct {
db *sql.DB
}
func NovoRepoProdutos(db *sql.DB) *RepoProdutosMySQL {
return &RepoProdutosMySQL{db: db}
}
func (r *RepoProdutosMySQL) Criar(ctx context.Context, input ProdutoInput) (*Produto, error) {
query := `
INSERT INTO produtos (categoria_id, nome, descricao, preco, estoque)
VALUES (?, ?, ?, ?, ?)
`
var descNullable sql.NullString
if input.Descricao != "" {
descNullable = sql.NullString{String: input.Descricao, Valid: true}
}
resultado, err := r.db.ExecContext(ctx, query,
input.CategoriaID,
input.Nome,
descNullable,
input.Preco,
input.Estoque,
)
if err != nil {
return nil, fmt.Errorf("criar produto: %w", err)
}
id, err := resultado.LastInsertId()
if err != nil {
return nil, fmt.Errorf("obter ID: %w", err)
}
return r.BuscarPorID(ctx, uint(id))
}
func (r *RepoProdutosMySQL) BuscarPorID(ctx context.Context, id uint) (*Produto, error) {
query := `
SELECT id, categoria_id, nome, descricao, preco, estoque, ativo,
criado_em, atualizado_em
FROM produtos
WHERE id = ?
`
var p Produto
err := r.db.QueryRowContext(ctx, query, id).Scan(
&p.ID, &p.CategoriaID, &p.Nome, &p.Descricao,
&p.Preco, &p.Estoque, &p.Ativo, &p.CriadoEm, &p.AtualizadoEm,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("produto %d não encontrado", id)
}
if err != nil {
return nil, fmt.Errorf("buscar produto: %w", err)
}
return &p, nil
}
func (r *RepoProdutosMySQL) Listar(ctx context.Context, apenasAtivos bool, limite, offset int) ([]Produto, error) {
query := `
SELECT id, categoria_id, nome, descricao, preco, estoque, ativo,
criado_em, atualizado_em
FROM produtos
WHERE (? = false OR ativo = true)
ORDER BY nome
LIMIT ? OFFSET ?
`
rows, err := r.db.QueryContext(ctx, query, apenasAtivos, limite, offset)
if err != nil {
return nil, fmt.Errorf("listar produtos: %w", err)
}
defer rows.Close()
var produtos []Produto
for rows.Next() {
var p Produto
if err := rows.Scan(
&p.ID, &p.CategoriaID, &p.Nome, &p.Descricao,
&p.Preco, &p.Estoque, &p.Ativo, &p.CriadoEm, &p.AtualizadoEm,
); err != nil {
return nil, fmt.Errorf("scan produto: %w", err)
}
produtos = append(produtos, p)
}
return produtos, rows.Err()
}
func (r *RepoProdutosMySQL) Atualizar(ctx context.Context, id uint, input ProdutoInput) error {
query := `
UPDATE produtos
SET categoria_id = ?, nome = ?, descricao = ?, preco = ?, estoque = ?
WHERE id = ?
`
var descNullable sql.NullString
if input.Descricao != "" {
descNullable = sql.NullString{String: input.Descricao, Valid: true}
}
resultado, err := r.db.ExecContext(ctx, query,
input.CategoriaID, input.Nome, descNullable,
input.Preco, input.Estoque, id,
)
if err != nil {
return fmt.Errorf("atualizar produto: %w", err)
}
if n, _ := resultado.RowsAffected(); n == 0 {
return fmt.Errorf("produto %d não encontrado", id)
}
return nil
}
func (r *RepoProdutosMySQL) Deletar(ctx context.Context, id uint) error {
resultado, err := r.db.ExecContext(ctx,
"DELETE FROM produtos WHERE id = ?", id)
if err != nil {
return fmt.Errorf("deletar produto: %w", err)
}
if n, _ := resultado.RowsAffected(); n == 0 {
return fmt.Errorf("produto %d não encontrado", id)
}
return nil
}
Transação: criando pedido com itens
O caso de uso mais comum de transações é criar um registro principal e seus registros filhos atomicamente:
type ItemPedidoInput struct {
ProdutoID uint
Quantidade int
}
type PedidoInput struct {
ClienteNome string
Itens []ItemPedidoInput
}
func (r *RepoProdutosMySQL) CriarPedido(ctx context.Context, input PedidoInput) (uint, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("iniciar transação: %w", err)
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
// Insere o pedido
resultado, err := tx.ExecContext(ctx,
"INSERT INTO pedidos (cliente_nome) VALUES (?)",
input.ClienteNome,
)
if err != nil {
return 0, fmt.Errorf("criar pedido: %w", err)
}
pedidoID, err := resultado.LastInsertId()
if err != nil {
return 0, fmt.Errorf("obter ID do pedido: %w", err)
}
var totalPedido float64
for _, item := range input.Itens {
// Busca preço atual e verifica estoque
var preco float64
var estoque int
err = tx.QueryRowContext(ctx,
"SELECT preco, estoque FROM produtos WHERE id = ? AND ativo = true FOR UPDATE",
item.ProdutoID,
).Scan(&preco, &estoque)
if err == sql.ErrNoRows {
return 0, fmt.Errorf("produto %d não encontrado ou inativo", item.ProdutoID)
}
if err != nil {
return 0, fmt.Errorf("verificar produto: %w", err)
}
if estoque < item.Quantidade {
return 0, fmt.Errorf("produto %d: estoque insuficiente (%d disponíveis)",
item.ProdutoID, estoque)
}
// Insere o item do pedido
_, err = tx.ExecContext(ctx,
`INSERT INTO itens_pedido (pedido_id, produto_id, quantidade, preco_unit)
VALUES (?, ?, ?, ?)`,
pedidoID, item.ProdutoID, item.Quantidade, preco,
)
if err != nil {
return 0, fmt.Errorf("inserir item: %w", err)
}
// Atualiza estoque
_, err = tx.ExecContext(ctx,
"UPDATE produtos SET estoque = estoque - ? WHERE id = ?",
item.Quantidade, item.ProdutoID,
)
if err != nil {
return 0, fmt.Errorf("atualizar estoque: %w", err)
}
totalPedido += preco * float64(item.Quantidade)
}
// Atualiza total do pedido
_, err = tx.ExecContext(ctx,
"UPDATE pedidos SET total = ?, status = 'confirmado' WHERE id = ?",
totalPedido, pedidoID,
)
if err != nil {
return 0, fmt.Errorf("atualizar total: %w", err)
}
if err = tx.Commit(); err != nil {
return 0, fmt.Errorf("confirmar transação: %w", err)
}
return uint(pedidoID), nil
}
Query com JOIN
type ProdutoComCategoria struct {
ProdutoID uint
ProdutoNome string
Preco float64
CategoriaNome string
}
func listarProdutosComCategoria(ctx context.Context, db *sql.DB) ([]ProdutoComCategoria, error) {
query := `
SELECT p.id, p.nome, p.preco, c.nome AS categoria_nome
FROM produtos p
INNER JOIN categorias c ON c.id = p.categoria_id
WHERE p.ativo = true
ORDER BY c.nome, p.nome
`
rows, err := db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var lista []ProdutoComCategoria
for rows.Next() {
var item ProdutoComCategoria
if err := rows.Scan(
&item.ProdutoID,
&item.ProdutoNome,
&item.Preco,
&item.CategoriaNome,
); err != nil {
return nil, err
}
lista = append(lista, item)
}
return lista, rows.Err()
}
Migrações com SQL puro
Para projetos sem ORM, migrações com SQL puro são simples e controladas:
package main
import (
"context"
"database/sql"
"fmt"
"log"
)
var migracoes = []struct {
versao int
sql string
}{
{1, `CREATE TABLE IF NOT EXISTS schema_versao (versao INT PRIMARY KEY)`},
{2, `ALTER TABLE produtos ADD COLUMN peso_kg DECIMAL(8,3)`},
{3, `CREATE INDEX idx_produtos_categoria ON produtos(categoria_id)`},
}
func executarMigracoes(ctx context.Context, db *sql.DB) error {
// Cria tabela de controle se necessário
_, err := db.ExecContext(ctx,
`CREATE TABLE IF NOT EXISTS schema_versao (versao INT PRIMARY KEY)`)
if err != nil {
return fmt.Errorf("criar tabela de versão: %w", err)
}
// Verifica versão atual
var versaoAtual int
err = db.QueryRowContext(ctx,
"SELECT COALESCE(MAX(versao), 0) FROM schema_versao").Scan(&versaoAtual)
if err != nil {
return fmt.Errorf("verificar versão: %w", err)
}
for _, m := range migracoes {
if m.versao <= versaoAtual {
continue
}
log.Printf("Aplicando migração %d...", m.versao)
if _, err := db.ExecContext(ctx, m.sql); err != nil {
return fmt.Errorf("migração %d: %w", m.versao, err)
}
if _, err := db.ExecContext(ctx,
"INSERT INTO schema_versao (versao) VALUES (?)", m.versao); err != nil {
return fmt.Errorf("registrar migração %d: %w", m.versao, err)
}
log.Printf("Migração %d aplicada com sucesso", m.versao)
}
return nil
}
Resumo do que foi coberto
Este artigo apresentou MySQL e MariaDB com Go em profundidade: configuração do DSN com a struct mysql.Config, mapeamento de tipos Go para MySQL, valores NULL com sql.Null*, um repositório completo de produtos com todas as operações CRUD, transações com múltiplos statements e controle de estoque, queries com JOIN e um sistema simples de migrações. O próximo artigo explora SQLite para aplicações que precisam de um banco embutido.
Referências e leituras complementares
-
go-sql-driver/mysql — Documentação oficial do driver MySQL para Go. https://github.com/go-sql-driver/mysql
-
Documentação MySQL 8.0 — Referência completa do MySQL. https://dev.mysql.com/doc/refman/8.0/en/
-
Documentação MariaDB — Referência do MariaDB com compatibilidade MySQL. https://mariadb.com/kb/en/documentation/
-
golang-migrate — Ferramenta popular para migrações de banco de dados em Go. https://github.com/golang-migrate/migrate
-
sqlx — Extensão do database/sql com helpers para scan em structs. https://github.com/jmoiron/sqlx
-
Go database tutorial — Tutorial oficial de acesso a MySQL com Go. https://go.dev/doc/tutorial/database-access