Leitura e escrita de arquivos com os pacotes os e bufio

Leitura e escrita de arquivos com os pacotes os e bufio

Arquivos em Go: ReadFile e WriteFile, Open, Create e OpenFile, bufio.Scanner e seu limite de 64 KB, bufio.Writer e o Flush antes do erro, CSV que distingue fim de arquivo de linha malformada, io.Copy, WalkDir, temporários, permissões e umask e a gravação atômica por renomeação.
Golang

• • 16 min de leitura

Antes de bancos de dados, filas de mensagens e APIs REST, existe o arquivo. Logs, configurações, dados exportados, relatórios, backups — praticamente todo sistema lida com arquivos em algum ponto. Go oferece uma API de I/O coerente e bem projetada que vai desde operações simples de leitura e escrita até streaming eficiente de grandes volumes de dados.

A abstração central é a interface io.Reader e io.Writer — qualquer coisa que implemente essas interfaces pode ser lida ou escrita com as mesmas funções. Arquivos, conexões de rede, buffers em memória e respostas HTTP são todos tratados uniformemente por esse sistema.

Operações simples: os.ReadFile e os.WriteFile

Para arquivos pequenos que cabem confortavelmente na memória, as funções de conveniência do pacote os são a escolha mais direta:

package main

import (
    "fmt"
    "os"
)

func main() {
    // Escrevendo um arquivo completo
    conteudo := []byte("linha 1\nlinha 2\nlinha 3\n")
    err := os.WriteFile("dados.txt", conteudo, 0644)
    if err != nil {
        fmt.Println("Erro ao escrever:", err)
        return
    }

    // Lendo o arquivo completo de uma vez
    dados, err := os.ReadFile("dados.txt")
    if err != nil {
        fmt.Println("Erro ao ler:", err)
        return
    }

    fmt.Print(string(dados))
    // linha 1
    // linha 2
    // linha 3
}

O argumento 0644 define as permissões do arquivo no formato octal Unix: o dono pode ler e escrever, grupo e outros podem apenas ler. Para arquivos executáveis, 0755 é a convenção.

os.ReadFile lê o arquivo inteiro para um []byte em memória. Para arquivos de configuração, templates e dados pequenos, é perfeitamente adequado. Para arquivos de gigabytes, leitura incremental é obrigatória.

Abrindo arquivos com os.Open e os.Create

Para controle mais fino, os.Open abre um arquivo existente para leitura, e os.Create cria ou trunca um arquivo para escrita:

package main

import (
    "fmt"
    "io"
    "os"
)

func main() {
    // os.Open — somente leitura
    arquivo, err := os.Open("dados.txt")
    if err != nil {
        fmt.Println("Erro:", err)
        return
    }
    defer arquivo.Close() // sempre fechar — defer garante mesmo em caso de erro

    // Lendo em chunks
    buffer := make([]byte, 32)
    for {
        n, err := arquivo.Read(buffer)
        if n > 0 {
            fmt.Print(string(buffer[:n]))
        }
        if err == io.EOF {
            break
        }
        if err != nil {
            fmt.Println("Erro na leitura:", err)
            return
        }
    }
}
// os.Create — cria ou trunca para escrita
func escreverArquivo() error {
    arquivo, err := os.Create("saida.txt")
    if err != nil {
        return fmt.Errorf("criar arquivo: %w", err)
    }
    defer arquivo.Close()

    _, err = fmt.Fprintln(arquivo, "primeira linha")
    if err != nil {
        return fmt.Errorf("escrever: %w", err)
    }

    _, err = arquivo.WriteString("segunda linha\n")
    return err
}

os.OpenFile: controle total sobre o modo de abertura

os.OpenFile é a função mais completa para abrir arquivos, permitindo especificar combinações de flags:

package main

import (
    "fmt"
    "os"
)

func main() {
    // Abrindo para append — adiciona ao final sem truncar
    arquivo, err := os.OpenFile("log.txt",
        os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
    if err != nil {
        fmt.Println("Erro:", err)
        return
    }
    defer arquivo.Close()

    fmt.Fprintln(arquivo, "nova entrada de log")
}

As flags mais usadas:

Flag Significado
os.O_RDONLY Somente leitura
os.O_WRONLY Somente escrita
os.O_RDWR Leitura e escrita
os.O_APPEND Adiciona ao final
os.O_CREATE Cria se não existir
os.O_TRUNC Trunca ao abrir
os.O_EXCL Falha se já existir

bufio: I/O eficiente com buffer

Cada chamada a arquivo.Read() ou arquivo.Write() é potencialmente uma chamada ao sistema operacional — uma operação cara. O pacote bufio envolve qualquer io.Reader ou io.Writer com um buffer em memória, reduzindo drasticamente o número de chamadas ao SO ao agrupar operações pequenas:

package main

import (
    "bufio"
    "fmt"
    "os"
)

func lerLinhas(caminho string) error {
    arquivo, err := os.Open(caminho)
    if err != nil {
        return fmt.Errorf("lerLinhas: %w", err)
    }
    defer arquivo.Close()

    scanner := bufio.NewScanner(arquivo)

    numeroLinha := 0
    for scanner.Scan() {
        numeroLinha++
        linha := scanner.Text()
        fmt.Printf("%4d: %s\n", numeroLinha, linha)
    }

    return scanner.Err() // sempre verificar erro ao final
}

func main() {
    // Cria arquivo de exemplo
    os.WriteFile("poema.txt", []byte(`Programar em Go
é como respirar —
simples, natural.`), 0644)

    if err := lerLinhas("poema.txt"); err != nil {
        fmt.Println("Erro:", err)
    }
}

bufio.Scanner é a forma idiomática de ler um arquivo linha por linha. Por padrão, divide por linhas — o token padrão é bufio.ScanLines. Mas pode ser configurado para dividir por palavras, bytes ou qualquer delimitador personalizado:

scanner := bufio.NewScanner(arquivo)
scanner.Split(bufio.ScanWords) // divide por palavras

for scanner.Scan() {
    fmt.Println(scanner.Text())
}

Atenção ao tamanho máximo de linha. Por padrão, bufio.Scanner suporta linhas de até 64 KB. Para linhas maiores, aumente o buffer:

scanner := bufio.NewScanner(arquivo)
buffer := make([]byte, 1024*1024) // buffer de 1 MB
scanner.Buffer(buffer, len(buffer))

bufio.Writer: escrita com buffer

Assim como o Scanner eficientiza a leitura, bufio.Writer agrupa escritas pequenas:

package main

import (
    "bufio"
    "fmt"
    "os"
)

func gerarRelatorio(caminho string, dados []string) error {
    arquivo, err := os.Create(caminho)
    if err != nil {
        return fmt.Errorf("criar arquivo: %w", err)
    }
    defer arquivo.Close()

    writer := bufio.NewWriter(arquivo)
    // bufio.NewWriterSize(arquivo, 65536) — tamanho de buffer personalizado

    for i, linha := range dados {
        _, err := fmt.Fprintf(writer, "%04d: %s\n", i+1, linha)
        if err != nil {
            return fmt.Errorf("escrever linha %d: %w", i+1, err)
        }
    }

    // CRÍTICO: Flush descarrega o buffer para o arquivo
    // Sem Flush, dados podem ser perdidos
    if err := writer.Flush(); err != nil {
        return fmt.Errorf("flush: %w", err)
    }

    return nil
}

func main() {
    dados := []string{
        "Relatório de vendas — Março 2024",
        "Produto A: 1500 unidades",
        "Produto B: 890 unidades",
        "Produto C: 2100 unidades",
        "Total: 4490 unidades",
    }

    if err := gerarRelatorio("relatorio.txt", dados); err != nil {
        fmt.Println("Erro:", err)
        return
    }
    fmt.Println("Relatório gerado com sucesso")
}

Lendo arquivos CSV

CSV é um dos formatos de arquivo mais comuns em dados empresariais. O pacote encoding/csv da biblioteca padrão lida com todas as particularidades do formato:

package main

import (
    "encoding/csv"
    "fmt"
    "io"
    "os"
    "strconv"
)

type Venda struct {
    Produto    string
    Quantidade int
    Preco      float64
}

func lerCSV(caminho string) ([]Venda, error) {
    arquivo, err := os.Open(caminho)
    if err != nil {
        return nil, fmt.Errorf("lerCSV: %w", err)
    }
    defer arquivo.Close()

    reader := csv.NewReader(arquivo)
    reader.Comma = ','         // separador padrão
    reader.Comment = '#'       // linhas começando com # são ignoradas
    reader.TrimLeadingSpace = true

    // Pula o cabeçalho
    if _, err := reader.Read(); err != nil {
        return nil, fmt.Errorf("ler cabeçalho: %w", err)
    }

    var vendas []Venda
    for {
        registro, err := reader.Read()
        if err == io.EOF {
            break // fim do arquivo
        }
        if err != nil {
            // linha malformada: parar com erro, e não fingir que o arquivo acabou
            return nil, fmt.Errorf("lerCSV: %w", err)
        }

        qtd, err := strconv.Atoi(registro[1])
        if err != nil {
            return nil, fmt.Errorf("lerCSV: quantidade de %q: %w", registro[0], err)
        }
        preco, err := strconv.ParseFloat(registro[2], 64)
        if err != nil {
            return nil, fmt.Errorf("lerCSV: preço de %q: %w", registro[0], err)
        }

        vendas = append(vendas, Venda{
            Produto:    registro[0],
            Quantidade: qtd,
            Preco:      preco,
        })
    }

    return vendas, nil
}

func escreverCSV(caminho string, vendas []Venda) error {
    arquivo, err := os.Create(caminho)
    if err != nil {
        return err
    }
    defer arquivo.Close()

    writer := csv.NewWriter(arquivo)

    // Cabeçalho
    writer.Write([]string{"Produto", "Quantidade", "Preco"})

    for _, v := range vendas {
        writer.Write([]string{
            v.Produto,
            strconv.Itoa(v.Quantidade),
            strconv.FormatFloat(v.Preco, 'f', 2, 64),
        })
    }

    // Flush antes de Error: com defer, o Error seria lido antes do flush final
    writer.Flush()
    return writer.Error()
}

func main() {
    // Cria CSV de exemplo
    os.WriteFile("vendas.csv", []byte(
        "Produto,Quantidade,Preco\n"+
            "Notebook,5,4500.00\n"+
            "Mouse,20,120.00\n"+
            "Teclado,15,250.00\n",
    ), 0644)

    vendas, err := lerCSV("vendas.csv")
    if err != nil {
        fmt.Println("Erro:", err)
        return
    }

    total := 0.0
    for _, v := range vendas {
        subtotal := float64(v.Quantidade) * v.Preco
        fmt.Printf("%-12s %3d x R$ %8.2f = R$ %10.2f\n",
            v.Produto, v.Quantidade, v.Preco, subtotal)
        total += subtotal
    }
    fmt.Printf("%-12s %27s R$ %10.2f\n", "TOTAL", "", total)
}

Copiando arquivos eficientemente

io.Copy transfere dados de um Reader para um Writer sem carregar tudo em memória:

package main

import (
    "fmt"
    "io"
    "os"
)

func copiarArquivo(origem, destino string) error {
    src, err := os.Open(origem)
    if err != nil {
        return fmt.Errorf("abrir origem: %w", err)
    }
    defer src.Close()

    dst, err := os.Create(destino)
    if err != nil {
        return fmt.Errorf("criar destino: %w", err)
    }
    defer dst.Close()

    bytesCopiados, err := io.Copy(dst, src)
    if err != nil {
        return fmt.Errorf("copiar: %w", err)
    }

    fmt.Printf("copiados %d bytes de %s para %s\n",
        bytesCopiados, origem, destino)
    return nil
}

func main() {
    os.WriteFile("original.txt", []byte("conteúdo do arquivo original\n"), 0644)

    if err := copiarArquivo("original.txt", "copia.txt"); err != nil {
        fmt.Println("Erro:", err)
        return
    }

    dados, _ := os.ReadFile("copia.txt")
    fmt.Print(string(dados))
}

Percorrendo diretórios

Para listar arquivos recursivamente, filepath.WalkDir é a função moderna e eficiente (substitui o filepath.Walk mais antigo):

package main

import (
    "fmt"
    "io/fs"
    "os"
    "path/filepath"
    "strings"
)

func listarArquivosGo(raiz string) error {
    return filepath.WalkDir(raiz, func(caminho string, d fs.DirEntry, err error) error {
        if err != nil {
            return err // erro ao acessar o item
        }

        // Ignora diretórios ocultos (começam com ponto)
        if d.IsDir() && strings.HasPrefix(d.Name(), ".") {
            return filepath.SkipDir // não desce nesse diretório
        }

        // Processa apenas arquivos .go
        if !d.IsDir() && filepath.Ext(caminho) == ".go" {
            info, err := d.Info()
            if err != nil {
                return err
            }
            fmt.Printf("%-50s %8d bytes\n", caminho, info.Size())
        }

        return nil
    })
}

func main() {
    // Cria estrutura de diretórios de exemplo
    os.MkdirAll("projeto/cmd", 0755)
    os.MkdirAll("projeto/internal", 0755)
    os.WriteFile("projeto/main.go", []byte("package main"), 0644)
    os.WriteFile("projeto/cmd/serve.go", []byte("package cmd"), 0644)
    os.WriteFile("projeto/internal/handler.go", []byte("package internal"), 0644)

    fmt.Println("Arquivos Go encontrados:")
    if err := listarArquivosGo("projeto"); err != nil {
        fmt.Println("Erro:", err)
    }

    // Limpeza
    os.RemoveAll("projeto")
}

Arquivos temporários

Para operações que precisam de arquivos temporários sem poluir o sistema de arquivos:

package main

import (
    "fmt"
    "os"
)

func main() {
    // Arquivo temporário — Go garante nome único
    tmp, err := os.CreateTemp("", "processamento-*.json")
    if err != nil {
        fmt.Println("Erro:", err)
        return
    }
    defer os.Remove(tmp.Name()) // remove ao terminar
    defer tmp.Close()

    fmt.Println("Arquivo temporário:", tmp.Name())
    tmp.WriteString(`{"status": "processando"}`)

    // Diretório temporário
    dir, err := os.MkdirTemp("", "cache-*")
    if err != nil {
        fmt.Println("Erro:", err)
        return
    }
    defer os.RemoveAll(dir) // remove recursivamente ao terminar

    fmt.Println("Diretório temporário:", dir)
}

Arquivos em Go passam pelas mesmas interfaces de todo o resto do I/O — io.Reader e io.Writer —, e por isso o que vale aqui vale para rede, compressão e HTTP. Os cuidados que mais pesam estão nas pontas: distinguir o fim do arquivo de um erro de leitura, dar Flush antes de conferir o erro, não descartar o erro do Close numa escrita e, quando um arquivo não pode ficar pela metade, gravar num temporário e renomear. São detalhes pequenos que separam um script que funciona de um que entrega números errados sem avisar.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Numa versão anterior deste artigo, o laço do lerCSV fazia if err != nil { break }, com o comentário "io.EOF encerra o loop", e ignorava os erros de strconv. Um arquivo chega com a segunda linha de dados sem a coluna de preço. O que o relatório mostrava, e por que esse é o pior tipo de falha para um relatório financeiro?

Ver resposta

✓ Resposta: Mostrava um relatório aparentemente normal, só que incompleto. O csv.Reader devolve um erro na linha com número errado de campos, e o break tratava esse erro como se fosse o fim do arquivo: a leitura parava ali, as linhas seguintes nunca eram lidas e a função devolvia nil. Num teste com a linha do Mouse sem o preço, o relatório listou só o Notebook, com total de R$ 22.500,00 no lugar dos R$ 28.650,00 corretos, e nenhuma mensagem de erro. É o pior tipo de falha porque não parece falha: ninguém desconfia de um número bem formatado, e a diferença só aparece quando alguém confere com outra fonte. A versão corrigida distingue io.EOF dos demais erros e os devolve com contexto; com o mesmo arquivo, ela para com lerCSV: record on line 3: wrong number of fields. Os erros de strconv seguem a mesma lógica: um _ ali transforma um preço ilegível em zero e some com o valor do total.

Exercício 2

A função escreverCSV tinha defer writer.Flush() logo depois de criar o writer e terminava com return writer.Error(). Parece correto: o flush acontece de qualquer jeito, e o erro é verificado. Onde está o problema? E que outro erro ainda fica sem verificação na mesma função?

Ver resposta

✓ Resposta: Está na ordem. A expressão do return é avaliada antes de os defer rodarem. writer.Error() é consultado primeiro e informa apenas os erros das escritas e dos flushes anteriores; só depois o Flush adiado envia o último bloco ao arquivo, e, se esse flush falhar, com disco cheio por exemplo, o erro dele não é visto por ninguém. Os registros que estavam no buffer somem, e a função devolve nil. A correção é chamar writer.Flush() explicitamente antes do return writer.Error(), como a versão atual faz. O outro erro esquecido é o do arquivo.Close(), adiado com defer e descartado. Em escrita, o fechamento pode ser o momento em que o sistema de arquivos reporta a falha, em especial em discos de rede. A forma completa usa retorno nomeado: defer func() { if cerr := arquivo.Close(); err == nil { err = cerr } }(), ou errors.Join para não perder nenhum dos dois.

Exercício 3

Um serviço grava um arquivo de chaves com os.WriteFile(caminho, dados, 0600). Numa auditoria, descobre-se que o arquivo em produção tem permissão -rw-r--r--, legível por todos. Nenhum código chama chmod. Como isso é possível, e como garantir a permissão?

Ver resposta

✓ Resposta: A permissão passada ao WriteFile só vale quando ele cria o arquivo. Se o arquivo já existia, criado por uma versão antiga do serviço, por um script de deploy ou copiado à mão, o WriteFile só trunca e reescreve o conteúdo, mantendo a permissão que ele já tinha. Num teste, um arquivo criado com 0600 e depois reescrito com 0644 continuou -rw-------; o caminho inverso produz exatamente o problema da auditoria. Há ainda o umask do processo, que só retira permissões: com o umask comum de 0022, um 0666 vira -rw-r--r--, o que foi medido no mesmo teste. Para arquivos sensíveis, a permissão tem de ser garantida de forma explícita: os.Chmod(caminho, 0600) depois da escrita, ou, melhor, gravar num arquivo temporário criado com a permissão certa e renomeá-lo sobre o destino, o que o exercício seguinte detalha. E um teste que confira info.Mode().Perm() depois da gravação pega a regressão antes da auditoria.

Exercício 4

Um programa atualiza um arquivo de configuração usando os.Create seguido da escrita do conteúdo novo. Numa queda de energia durante a escrita, o arquivo fica vazio, e o serviço não sobe mais. Como gravar de forma que o arquivo nunca fique pela metade?

Ver resposta

✓ Resposta: O os.Create trunca o arquivo na hora de abrir, então existe uma janela, entre o truncamento e o fim da escrita, em que o arquivo está vazio ou incompleto; qualquer interrupção nesse intervalo deixa esse estado para trás. A técnica padrão é a gravação atômica por renomeação. Primeiro, cria-se um arquivo temporário no mesmo diretório do destino, com os.CreateTemp(filepath.Dir(destino), ".config-*"). Em seguida, escreve-se o conteúdo, chama-se tmp.Sync() para forçar os dados ao disco e depois tmp.Close(), conferindo o erro de cada passo. Por último, os.Rename(tmp.Name(), destino). No Linux, renomear dentro do mesmo sistema de arquivos substitui o destino atomicamente: quem ler o arquivo vê o conteúdo antigo ou o novo inteiro, nunca uma mistura. O temporário precisa estar no mesmo diretório porque a renomeação entre sistemas de arquivos diferentes não é atômica e costuma falhar. Se algo der errado antes do Rename, basta remover o temporário, e o arquivo original continua intacto.

Exercício 5

Um script processa um log de 20 GB. A primeira versão usa os.ReadFile e é derrubada por falta de memória. A segunda usa bufio.Scanner, roda com memória baixa, mas termina na metade do arquivo sem nenhum erro aparente. O que mudou entre as duas e o que faltou na segunda?

Ver resposta

✓ Resposta: A primeira tenta pôr os 20 GB inteiros num []byte, e é exatamente o caso em que o artigo avisa que a leitura incremental é obrigatória. A segunda lê em blocos, com um buffer de tamanho fixo, e por isso a memória fica baixa independentemente do tamanho do arquivo. O término na metade tem outra causa: alguma linha do log passou do limite padrão do Scanner, que é de 64 KB por linha, algo comum em logs com JSON grande ou pilhas de erro numa linha só. Ao encontrar essa linha, Scan() devolve false, o laço termina como se o arquivo tivesse acabado, e o erro bufio.Scanner: token too long fica guardado em scanner.Err(). Faltaram duas coisas: verificar scanner.Err() depois do laço, como o artigo faz em lerLinhas, e aumentar o limite com scanner.Buffer para o maior tamanho de linha plausível. Para dados sem limite razoável de linha, bufio.Reader com ReadString('\n') não tem teto, ao custo de alocar uma string por linha.

Comentários

Mais em Golang

Composição vs herança: o jeito Go de reutilizar código
Composição vs herança: o jeito Go de reutilizar código

Composição em vez de herança em Go: embedding de structs e de interfaces…

Estudando e se aprofundando em Go
Estudando e se aprofundando em Go

Por que aprender Go hoje: a linguagem de Docker e Kubernetes, o que ela…

Testes em Go: testing, tabelas de casos e benchmarks
Testes em Go: testing, tabelas de casos e benchmarks

Testes em Go: arquivos _test.go, Error e Fatal, tabelas de casos com t.Run…