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.4 # 8.4 é a versão de suporte longo; a 8.0 chegou ao fim de vida em abril de 2026
# 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 inteiro em centavos — float64 perde precisão e não serve para valores monetários |
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)
}
// Rollback incondicional: sem efeito depois do Commit, e cobre todo return antecipado.
// Um "if err != nil" aqui falharia nos returns que criam um erro novo sem atribuí-lo a err.
defer 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
}
Com o go-sql-driver/mysql, MySQL e MariaDB se comportam como qualquer banco do database/sql, e as particularidades ficam na configuração e no esquema: ParseTime e Loc para as datas, utf8mb4 para caracteres de verdade, DECIMAL para dinheiro, sem passar por float64. Nas transações, o defer tx.Rollback() incondicional evita a armadilha que a versão original deste artigo tinha; nas migrações, lembre que DDL no MySQL confirma sozinho e não volta atrás. O próximo passo, o SQLite, troca o servidor por um arquivo, com outro conjunto de cuidados.
Fontes e leituras recomendadas
-
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
Exercícios
Exercício 1
A primeira versão do CriarPedido deste artigo desfazia a transação com defer func() { if err != nil { tx.Rollback() } }(). Um cliente pede 5 unidades de um produto com estoque 2, recebe "estoque insuficiente", e logo depois o serviço começa a travar. Por que o rollback não aconteceu nesse caso?
Ver resposta
✓ Resposta: Porque a função tem retornos sem nome, (uint, error), e o err que o defer examina é a variável local declarada em tx, err := r.db.BeginTx(...). No caminho do estoque insuficiente, a última operação que gravou em err foi o Scan, que deu certo, e o erro devolvido é criado na própria linha do return 0, fmt.Errorf("... estoque insuficiente ..."), sem passar pela variável. O defer vê err == nil e não desfaz nada. A transação fica aberta, com o pedido já inserido e o SELECT ... FOR UPDATE segurando o lock da linha do produto; a conexão não volta ao pool. Numa reprodução com SQLite e pool de uma conexão, a consulta seguinte ao pedido recusado esperou até o prazo, com uma conexão marcada como em uso. Com retornos nomeados, (id uint, err error), o return atribuiria o erro e o defeito sumiria, o que mostra quanto esse padrão é frágil. A correção aplicada é defer tx.Rollback(), incondicional, que não faz nada depois do Commit.
Exercício 2
Uma tabela de produtos foi criada há anos com CHARACTER SET utf8. Um cliente cadastra um produto com um emoji no nome e o INSERT falha com Incorrect string value. O artigo usa utf8mb4 em todo lugar. Qual é a diferença, e o que precisa ser ajustado além da tabela?
Ver resposta
✓ Resposta: No MySQL, utf8 é, historicamente, um apelido para utf8mb3, uma codificação que guarda no máximo 3 bytes por caractere. O UTF-8 de verdade usa até 4, e os caracteres fora do plano básico do Unicode, como emojis e alguns ideogramas, precisam dos 4 bytes. Por isso a coluna utf8 recusa o valor. O utf8mb4 é o UTF-8 completo, e é por isso que o artigo o usa no banco, na tabela e na Collation da conexão. A correção tem três partes. Primeiro, converter a tabela, com ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4, conferindo antes os índices em colunas VARCHAR longas, que podem passar do limite de tamanho de chave com 4 bytes por caractere. Segundo, garantir que a conexão também use utf8mb4, com Collation: "utf8mb4_unicode_ci" na mysql.Config, como no artigo, ou charset=utf8mb4 no DSN. Terceiro, testar com um emoji de verdade, porque um teste só com acentos passa nas duas codificações.
Exercício 3
O sistema de migrações do artigo executa cada comando e depois registra a versão em schema_versao. A migração 2, ALTER TABLE produtos ADD COLUMN peso_kg ..., é aplicada, mas o processo cai antes do INSERT da versão. Na próxima subida, tudo falha. Por quê, e por que envolver as duas coisas numa transação não resolve no MySQL?
Ver resposta
✓ Resposta: Na próxima subida, a versão registrada ainda é 1, então a migração 2 roda de novo, e o ADD COLUMN falha porque a coluna já existe, com o erro de coluna duplicada; o serviço não sobe. O estado do banco e o registro das versões divergiram. Numa base como o PostgreSQL, a solução natural seria executar a migração e o registro na mesma transação. No MySQL, porém, comandos DDL como CREATE, ALTER e DROP fazem um commit implícito: eles confirmam o que estiver pendente e não podem ser desfeitos, então a transação não protege nada. As saídas práticas são três. Escrever migrações idempotentes, que conferem o estado antes de agir, consultando information_schema. Manter cada migração com um único comando DDL, para que a janela de inconsistência seja mínima. Ou usar uma ferramenta de migração, como golang-migrate ou goose, que marca a migração como "suja" quando algo falha no meio e exige uma decisão explícita antes de continuar, em vez de tentar de novo às cegas.
Exercício 4
O repositório lê a coluna preco DECIMAL(10,2) num float64, e o CriarPedido soma preco * float64(item.Quantidade) em Go antes de gravar o total. Um relatório financeiro compara esse total com SUM(quantidade * preco_unit) calculado pelo banco e encontra diferenças de centavos. De onde vêm, e como evitá-las?
Ver resposta
✓ Resposta: O DECIMAL do MySQL guarda o valor exato em base 10: 19,99 é exatamente 19,99. Ao ler para um float64, o valor é convertido para a aproximação binária mais próxima, que não é exata, e a soma em Go acumula esses pequenos erros, como o artigo sobre operadores mostrou com o 19,99 que virou 1998,9999999999998 centavos. O banco, somando em DECIMAL, faz a conta exata, e os dois totais divergem na última casa. Há três formas de evitar. A mais simples é deixar o cálculo com o banco: UPDATE pedidos SET total = (SELECT SUM(quantidade * preco_unit) FROM itens_pedido WHERE pedido_id = ?), dentro da mesma transação. A segunda é ler o DECIMAL como string e usar uma biblioteca decimal em Go, como shopspring/decimal, para qualquer conta. A terceira é modelar os valores em centavos, como inteiros, desde a tabela. O float64 só é aceitável para exibir, nunca para somar e gravar.
Exercício 5
A configuração do artigo define MultiStatements: false e InterpolateParams: false, com o comentário "segurança". Um desenvolvedor liga MultiStatements para rodar um script de migração com vários comandos de uma vez. Que risco isso traz para o resto da aplicação, e qual é a forma mais segura de atender à necessidade dele?
Ver resposta
✓ Resposta: Com MultiStatements desligado, o servidor recusa texto com mais de um comando separado por ponto e vírgula. Isso é uma rede de segurança contra o tipo mais destrutivo de injeção de SQL: se algum ponto da aplicação concatenar um valor do usuário, um trecho como '; DROP TABLE produtos; -- não consegue anexar um segundo comando. Ligar a opção na configuração compartilhada remove essa rede para toda a aplicação, e não só para a migração: qualquer falha de concatenação esquecida passa a permitir comandos arbitrários. Os placeholders continuam protegendo o código bem escrito, mas a defesa em profundidade se perde. A forma segura é separar as coisas. A migração usa uma conexão própria, aberta só para ela, com MultiStatements ligado e, de preferência, com um usuário do banco que tenha permissão de DDL, enquanto a aplicação usa outro usuário, sem essa permissão e com a opção desligada. Ou então o script é dividido em comandos e executado um a um, que é o que as ferramentas de migração costumam fazer.