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.
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 é.
- 01 Como importar CSV sem corromper caracteres: encoding, BOM e delimitadores UTF-8, latin1, cp1252, BOM — por que seus acentos aparecem como lixo e como corrigir de uma vez, no Excel, Python e linha de comando.
- 02 IP público, privado, fixo e dinâmico: qual a diferença? RFC 1918, NAT e DHCP explicados de forma direta: o que separa IP público de privado e por que confundir fixo com dinâmico derruba servidores em produção.