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
-
Documentação do pacote os — Referência completa de operações de arquivo e sistema. https://pkg.go.dev/os
-
Documentação do pacote bufio — Referência de Scanner, Reader e Writer com buffer. https://pkg.go.dev/bufio
-
Documentação do pacote io — Interfaces Reader, Writer e funções utilitárias. https://pkg.go.dev/io
-
Documentação encoding/csv — Leitura e escrita de arquivos CSV. https://pkg.go.dev/encoding/csv
-
Go by Example: Reading Files — Exemplos práticos de leitura de arquivos. https://gobyexample.com/reading-files
-
Go by Example: Writing Files — Exemplos práticos de escrita de arquivos. https://gobyexample.com/writing-files
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.