Go com MySQL e MariaDB: conexão, queries e transações

Go com MySQL e MariaDB: conexão, queries e transações

Go com MySQL e MariaDB: mysql.Config, ParseTime e Loc, tipos e sql.Null*, repositório CRUD, a transação que ficava aberta num return antecipado e o defer tx.Rollback, utf8mb4 contra utf8, DECIMAL sem float64, migrações com o DDL que confirma sozinho e MultiStatements.
Golang

• • 17 min de leitura

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

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.

Comentários

Mais em Golang

Linting, formatação e análise estática: gofmt, golangci-lint
Linting, formatação e análise estática: gofmt, golangci-lint

Qualidade de código em Go: gofmt, goimports, go vet e staticcheck com exemplos…

Métodos em Structs: receivers por valor e por ponteiro
Métodos em Structs: receivers por valor e por ponteiro

Métodos em Go: receiver por valor e por ponteiro, a conversão automática para…

Introdução ao Go: história, filosofia e os criadores da linguagem
Introdução ao Go: história, filosofia e os criadores da linguagem

A origem do Go: o build de 45 minutos que motivou Griesemer, Pike e Thompson…