Todos os artigos
162 artigos · atualizado semanalmente Veja nossas Ferramentas
Todos os artigos
Tutoriais

Como trabalhar com arquivos grandes sem estourar a memória

Por que readFile mata o processo em arquivos de 2GB e como processar em stream com memória constante: chunks, generators em Python e backpressure no Node.

Como trabalhar com arquivos grandes sem estourar a memória
COVER · Tutoriais

Você escreveu um script que lê um CSV, transforma cada linha e grava o resultado. Funcionou perfeito com o arquivo de exemplo de 10MB no seu laptop. Aí o cliente mandou o arquivo real de 2GB, o processo subiu de RAM até travar a máquina e morreu com um JavaScript heap out of memory (ou um MemoryError silencioso, no Python). O bug não está na sua lógica de transformação. Está em uma única linha: readFile (ou read()) que carrega o arquivo inteiro na memória antes de você sequer começar a processar.

Este artigo é sobre como evitar isso de forma estrutural: processar arquivos em fluxo (streaming), com memória constante, independente do tamanho do arquivo. Cobre Node.js e Python, o conceito de backpressure, e quando carregar tudo de uma vez ainda é aceitável.

Por que readFile mata o processo

A função que parece mais simples é a mais perigosa. fs.readFileSync(path) no Node e open(path).read() no Python fazem a mesma coisa: alocam um buffer do tamanho exato do arquivo e despejam todo o conteúdo nele antes de retornar. Um arquivo de 2GB exige no mínimo 2GB de RAM contígua só para existir como variável — e isso antes de você fazer qualquer parsing, que normalmente duplica ou triplica esse número (uma string de 2GB virando um array de objetos pode facilmente passar de 6GB).

O Node tem um limite de heap padrão na casa de 2GB a 4GB dependendo da versão. Estourou, o v8 aborta. O Python não tem esse teto fixo, mas o sistema operacional tem: quando a RAM acaba, o OOM killer derruba o processo, ou a máquina começa a fazer swap em disco e tudo fica mil vezes mais lento. Em qualquer dos casos, o resultado é o mesmo — um job que funcionava no teste falha em produção.

O ponto que importa: o tamanho do arquivo não é seu para controlar. Se a origem é upload de usuário, export de um banco, log de aplicação ou despejo de outro sistema, ele pode crescer sem aviso. O que cabe na memória hoje não cabe no mês que vem.

A solução: ler em chunks, processar, descartar

A ideia central do streaming é nunca ter o arquivo inteiro na memória ao mesmo tempo. Você lê um pedaço (um chunk, ou uma linha), processa, descarta, lê o próximo. A memória usada é a de um chunk, não a do arquivo. Processar 10MB ou 10GB consome praticamente a mesma RAM — o que muda é só o tempo.

No Node, a ferramenta é o stream. Em vez de readFile, você usa createReadStream e consome os dados conforme chegam:

import { createReadStream, createWriteStream } from 'node:fs';
import { createInterface } from 'node:readline';

const input = createReadStream('grande.csv');
const output = createWriteStream('saida.csv');
const rl = createInterface({ input, crlfDelay: Infinity });

for await (const linha of rl) {
  const transformada = processa(linha); // sua lógica, uma linha por vez
  output.write(transformada + '\n');
}
output.end();

O for await percorre linha a linha. Em nenhum momento o array completo de linhas existe — readline segura no máximo um chunk do buffer interno do stream.

No Python o equivalente é ainda mais direto, porque iterar sobre um file object já lê linha a linha por padrão:

with open("grande.csv") as entrada, open("saida.csv", "w") as saida:
    for linha in entrada:        # nunca carrega o arquivo todo
        saida.write(processa(linha))

A armadilha clássica em Python é entrada.readlines() ou list(entrada) — ambos materializam todas as linhas numa lista de uma vez, anulando a vantagem. Itere direto sobre o file object, ou escreva um generator que faz yield de uma linha (ou um registro) por vez. O generator é a versão Python do stream: produz valores sob demanda, sem acumular.

Backpressure: não leia mais rápido do que escreve

Streaming resolve o consumo de memória da leitura, mas introduz um segundo problema sutil. Imagine que você lê de um arquivo rápido (SSD) e escreve em algo lento (rede, ou um banco). Se você lê 100MB/s e só consegue gravar 10MB/s, a diferença se acumula em um buffer na memória — e você reinventou o problema do OOM por outro caminho.

A resposta é backpressure: o consumidor sinaliza ao produtor para pausar quando não dá conta. No Node, pipe e pipeline fazem isso automaticamente — é o motivo de você quase sempre dever usar pipeline em vez de encadear write na mão:

import { pipeline } from 'node:stream/promises';
import { createReadStream, createWriteStream } from 'node:fs';
import { createGzip } from 'node:zlib';

await pipeline(
  createReadStream('grande.csv'),
  createGzip(),                  // transforma no caminho
  createWriteStream('grande.csv.gz')
);

O pipeline propaga o backpressure ponta a ponta, fecha os streams corretamente no fim e propaga erros — três coisas que write manual erra com facilidade. Quando o destino fica lento, a leitura pausa sozinha. A memória permanece estável mesmo com origem e destino em velocidades muito diferentes.

Casos comuns na prática

CSV gigante. Processe linha a linha como nos exemplos acima. Nunca dê JSON.parse num CSV inteiro nem carregue tudo num array para depois fazer .map() — o .map() exige a lista completa em memória. Se você precisa só entender o formato antes de escrever o parser (quais colunas existem, como o separador se comporta, se há aspas), inspecione uma amostra: pegue as primeiras linhas e, para visualizar a estrutura de um CSV grande convertendo uma amostra para JSON, você vê o shape dos dados sem abrir o arquivo de 2GB. Depois escreve o stream com confiança.

Copiar ou transformar arquivo. Use pipeline com um Transform no meio (compressão, troca de encoding, reescrita de cada linha). Memória constante, e o backpressure cuida da diferença de velocidade entre origem e destino.

JSON enorme. Esse é o mais traiçoeiro, porque JSON.parse é tudo-ou-nada: ele precisa do documento inteiro para retornar. Para um array JSON de centenas de MB, use um streaming parser (no Node, bibliotecas como stream-json; no Python, ijson) que emite cada elemento conforme o lê. Se você controla a origem, prefira JSON Lines (um objeto JSON por linha) — aí o problema vira "ler linha a linha", que você já sabe resolver. Problemas de encoding também aparecem muito aqui; se o arquivo veio de outro sistema, vale entender o tema antes, como descrevo em importar CSV lidando com encoding.

Quando carregar tudo ainda é aceitável

Streaming não é gratuito: o código fica mais verboso e mais difícil de raciocinar. Se o arquivo é pequeno, conhecido e gerado por você — um arquivo de config de 4KB, um JSON de tradução de 200KB que faz parte do build — readFile é a escolha certa. Otimizar isso é desperdício de tempo e legibilidade.

O critério não é o tamanho atual do arquivo. É se a origem pode crescer. Config versionada no seu repositório? Carregue tudo. Qualquer coisa que venha de fora — upload, banco, API, log — trate o tamanho como não-confiável e use stream por padrão. O custo de escrever em stream desde o início é trivial perto do custo de descobrir, com o cliente furioso, que o seu script de import morre no arquivo real.

Perguntas frequentes

Streaming é mais lento que carregar tudo de uma vez?

Para arquivos pequenos, marginalmente sim, por causa do overhead de gerenciar o stream. Para arquivos grandes, é o oposto: carregar tudo força o sistema a fazer swap em disco quando a RAM acaba, e aí o desempenho desaba ordens de grandeza. Streaming mantém o working set pequeno e cache-friendly. Na faixa onde importa — arquivos grandes — o stream é mais rápido, além de simplesmente não travar.

Qual o tamanho ideal de chunk?

Para leitura linha a linha você não escolhe — a abstração cuida disso. Quando você controla o buffer (streams binários), o padrão do Node de 64KB é um bom ponto de partida. Chunks muito pequenos aumentam o overhead por iteração; muito grandes desperdiçam memória sem ganho real. Só ajuste com um número medido na frente, não por intuição.

Como sei que o backpressure está funcionando?

Monitore o uso de memória do processo durante uma execução com um arquivo grande (process.memoryUsage() no Node, ou observe o RSS no gerenciador de tarefas). Se a memória sobe e fica estável, o backpressure está fazendo o trabalho. Se sobe sem parar até o fim, em algum lugar você está acumulando — provavelmente um array.push dentro do loop, ou um write sem respeitar o retorno que indica buffer cheio. Usar pipeline elimina a maioria desses erros de antemão.

Generators em Python servem para o mesmo que streams no Node?

Para leitura, sim: um generator que faz yield de uma linha por vez tem o mesmo efeito de memória constante. A diferença é que Python não tem o conceito de backpressure embutido como o pipeline do Node — se você está escrevendo em um destino lento, precisa controlar o ritmo na mão ou usar asyncio com bibliotecas que suportem isso. Para o caso comum de ler-transformar-escrever em disco, iterar sobre o file object resolve.

O que levar daqui

Trate tamanho de arquivo como entrada não-confiável. O que funciona com 10MB no seu laptop morre com 10GB em produção, e a descoberta sempre acontece no pior momento possível. A regra prática é simples: se a origem do arquivo pode crescer, use stream por padrão — leia em chunks, processe, descarte, e deixe o backpressure cuidar do ritmo. Carregar tudo na memória é a exceção reservada para arquivos pequenos que você mesmo controla, não o caminho default. O custo de escrever em stream desde o começo é pequeno; o custo de reescrever sob pressão, depois do OOM em produção, não é.

RD
Autor
Rafael Duarte
Desenvolvedor backend com passagem por fintech e SaaS B2B — trabalhou em times que escalaram APIs de zero a milhões de requisições. Carrega cicatrizes de produção suficientes para ter opiniões fortes sobre ferramentas, padrões e decisões de arquitetura. Não é acadêmico: leu a RFC do UUID quando precisou escolher entre v4 e v7 para uma tabela de alta escrita.
Ver perfil