Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIniciantecódigo testado

Ler e escrever arquivo no Node com fs/promises e path

readFile, writeFile, appendFile e mkdir com async/await, o caminho certo com path.join e o erro ENOENT quando a pasta ainda não existe.

Rodolfo Mori11 min de leitura

São duas linhas: await readFile(caminho, 'utf8') traz o conteúdo do arquivo como texto, e await writeFile(caminho, texto) grava. As duas funções vêm de node:fs/promises. O que dá trabalho não é a chamada — é acertar o caminho e lidar com a pasta que ainda não existe.

Os exemplos desta lição são de uma livraria de bairro, a Livraria Aurora: o catálogo em JSON, os recibos do dia numa pasta e a exportação de vendas do ano.

As funções são diretas; a parte que mais confunde é a resolução de caminho. Em palavras simples, o Node precisa transformar o texto do caminho num endereço concreto, e um caminho relativo parte do diretório atual do processo.

O endereço do arquivo depende de onde você está

Uma instrução como “ande duas quadras e vire à direita” só funciona quando você sabe o ponto de partida. ./dados/catalogo.json é esse tipo de instrução: o resultado muda conforme o diretório em que o processo foi iniciado. Um caminho absoluto é o endereço completo. path.resolve e import.meta.dirname ajudam a deixar explícito qual ponto de partida o programa usa.

Antes da primeira leitura, anote process.cwd(), o caminho recebido e o caminho absoluto esperado. Depois imprima a resolução e confira se o arquivo está ali. Repita iniciando o mesmo script de outra pasta; a diferença confirma qual parte dependia do diretório atual.

O mesmo módulo de arquivos aparece em três formatos, e a confusão entre eles é a primeira pedra no caminho. Este script chama a mesma leitura nos três:

js
import { readFile, readFileSync } from 'node:fs';
import { readFile as readFilePromise } from 'node:fs/promises';

// 1. síncrono: devolve o conteúdo e trava a thread até terminar
console.log('sync ->', readFileSync('horario.txt', 'utf8').split('\n')[0]);

// 2. promise: devolve uma Promise, para usar com await
console.log('promise ->', readFilePromise('horario.txt', 'utf8'));

// 3. callback: não devolve nada, entrega o conteúdo na função do final
console.log('callback ->', readFile('horario.txt', 'utf8', (erro, texto) => {
  console.log('dentro do callback ->', texto.split('\n')[0]);
}));
sync -> Livraria Aurora promise -> Promise { <pending> } callback -> undefined dentro do callback -> Livraria Aurora

Três coisas diferentes saíram da mesma leitura. O readFileSync devolveu a string na hora. O de fs/promises devolveu uma Promise { <pending> } — é ela que o await desembrulha. E o de callback devolveu undefined, porque o conteúdo dele chega depois, na função do último argumento.

Repare também na ordem: a linha do callback foi impressa antes do conteúdo. A leitura foi para a fila e o resto do script continuou. Se isso ainda soa estranho, o event loop do Node explica por quê.

Para código de projeto, use node:fs/promises com await. O prefixo node: não é enfeite: ele diz ao Node que você quer o módulo interno, e não um pacote chamado fs que alguém instalou por engano.

Ler texto: o Buffer e o utf8

Sem o segundo argumento, readFile não devolve string — devolve um Buffer, que é a sequência crua de bytes do arquivo.

js
import { readFile } from 'node:fs/promises';

const bruto = await readFile('horario.txt');
const texto = await readFile('horario.txt', 'utf8');

console.log(bruto.subarray(0, 16));
console.log('bruto  ->', bruto.constructor.name, bruto.length);
console.log('texto  ->', typeof texto, texto.length);
console.log('sábado ->', texto.trimEnd().split('\n')[2]);
<Buffer 4c 69 76 72 61 72 69 61 20 41 75 72 6f 72 61 0a> bruto -> Buffer 82 texto -> string 79 sábado -> Sábado: 10h às 14h

4c 69 76 é L, i, v em hexadecimal. E olhe a diferença de tamanho: 82 bytes contra 79 caracteres. O arquivo tem três letras acentuadas, e cada uma ocupa dois bytes em UTF-8. Bytes e caracteres não são a mesma unidade — por isso 'utf8' importa.

Quando o arquivo é imagem, PDF ou ZIP, é o contrário: você quer o Buffer e não passa encoding nenhum, senão o Node tenta interpretar bytes binários como letras e corrompe o conteúdo.

Ler JSON — e por que o import engana

Arquivo de configuração e banco pequeno quase sempre são JSON. O padrão é ler como texto e entregar para o JSON.parse:

js
import { readFile } from 'node:fs/promises';

const catalogo = JSON.parse(await readFile('catalogo.json', 'utf8'));

console.log(catalogo.loja, '—', catalogo.livros.length, 'títulos');

const emFalta = catalogo.livros.filter((livro) => livro.estoque === 0);
console.log('em falta:', emFalta.map((livro) => livro.titulo));

const valorEmEstoque = catalogo.livros.reduce(
  (soma, livro) => soma + livro.preco * livro.estoque,
  0,
);
console.log('valor em estoque: R$', valorEmEstoque.toFixed(2));
Livraria Aurora — 3 títulos em falta: [ 'Vidas Secas' ] valor em estoque: R$ 1078.40

Depois do JSON.parse você tem um objeto JavaScript comum, com filter e reduce funcionando normalmente. Se a conversão entre texto e objeto ainda não está clara, vale revisar JSON.parse e JSON.stringify.

O Node 24 também deixa importar JSON direto, com import ... with { type: 'json' }. Parece mais limpo, e é uma armadilha em qualquer arquivo que muda:

js
import { readFile, writeFile } from 'node:fs/promises';

const primeira = await import('./catalogo.json', { with: { type: 'json' } });
console.log('import  (1ª vez) ->', primeira.default.livros[0].estoque);

// a loja vende um exemplar e o arquivo muda no disco
const catalogo = JSON.parse(await readFile('catalogo.json', 'utf8'));
catalogo.livros[0].estoque -= 1;
await writeFile('catalogo.json', JSON.stringify(catalogo, null, 2) + '\n');

const segunda = await import('./catalogo.json', { with: { type: 'json' } });
console.log('import  (2ª vez) ->', segunda.default.livros[0].estoque);
console.log('readFile agora   ->', JSON.parse(await readFile('catalogo.json', 'utf8')).livros[0].estoque);
import (1ª vez) -> 4 import (2ª vez) -> 4 readFile agora -> 3

O arquivo no disco já dizia 3, e o segundo import continuou devolvendo 4. Todo módulo importado fica em cache até o processo morrer, e JSON importado é módulo. Use import só para arquivo que nunca muda em produção; para dado vivo, sempre readFile.

Escrever, sobrescrever e acrescentar

writeFile cria o arquivo se ele não existir — e apaga tudo se existir. É o comportamento certo para salvar um estado inteiro, e o errado para um log:

js
import { writeFile, appendFile, readFile } from 'node:fs/promises';

await writeFile('vendas.log', '09:14 Torto Arado 59.90\n');
await writeFile('vendas.log', '10:02 Vidas Secas 42.50\n');

console.log('--- depois de dois writeFile ---');
console.log(await readFile('vendas.log', 'utf8'));

await appendFile('vendas.log', '11:47 Grande Sertão: Veredas 89.90\n');
await appendFile('vendas.log', '12:30 Torto Arado 59.90\n');

console.log('--- depois de dois appendFile ---');
console.log(await readFile('vendas.log', 'utf8'));
--- depois de dois writeFile --- 10:02 Vidas Secas 42.50

— depois de dois appendFile — 10:02 Vidas Secas 42.50 11:47 Grande Sertão: Veredas 89.90 12:30 Torto Arado 59.90

A venda das 09:14 desapareceu: o segundo writeFile truncou o arquivo antes de gravar. As duas linhas seguintes, com appendFile, entraram sem tocar no que já estava lá.

path.join, path.resolve e import.meta.dirname

Caminho montado na mão com + é fonte garantida de barra dobrada e barra faltando ('dados' + '/' + arquivo, e o dia em que arquivo já vem com barra na frente). path.join resolve isso e ainda usa o separador do sistema, o que importa no Windows. E import.meta.dirname responde a pergunta que quase todo mundo erra: onde o script está, e não de onde ele foi chamado.

js
import path from 'node:path';

console.log('cwd                ->', process.cwd());
console.log('pasta do script    ->', import.meta.dirname);
console.log('join               ->', path.join('dados', 'vendas.log'));
console.log('resolve            ->', path.resolve('dados', 'vendas.log'));
console.log('ao lado do script  ->', path.join(import.meta.dirname, '..', 'catalogo.json'));

Agora o mesmo arquivo, rodado de dois lugares diferentes:

bash
cd /private/tmp/livraria-aurora
node scripts/caminhos.mjs

cd /private/tmp
node livraria-aurora/scripts/caminhos.mjs
cwd -> /private/tmp/livraria-aurora pasta do script -> /private/tmp/livraria-aurora/scripts join -> dados/vendas.log resolve -> /private/tmp/livraria-aurora/dados/vendas.log ao lado do script -> /private/tmp/livraria-aurora/catalogo.json cwd -> /private/tmp pasta do script -> /private/tmp/livraria-aurora/scripts join -> dados/vendas.log resolve -> /private/tmp/dados/vendas.log ao lado do script -> /private/tmp/livraria-aurora/catalogo.json

O resolve mudou de resposta entre as duas execuções, porque ele parte do process.cwd() — a pasta em que você digitou node. O import.meta.dirname não mudou: ele é a pasta do próprio arquivo.

A regra prática: se o arquivo pertence ao projeto (um template, um seed, um JSON de configuração), monte o caminho a partir de import.meta.dirname. Se o caminho vem do usuário ou de uma variável de ambiente, aí sim ele é relativo ao cwd.

ENOENT: o arquivo (ou a pasta) que ainda não existe

ENOENT é o código POSIX de no such file or directory: o sistema operacional procurou naquele caminho e não achou nada. Ele é o erro mais comum de quem começa com arquivos, e aparece em dois sabores bem diferentes.

O primeiro é o caminho relativo lido do lugar errado. Este script funciona quando você roda de dentro da livraria:

js
import { readFile } from 'node:fs/promises';

const catalogo = JSON.parse(await readFile('catalogo.json', 'utf8'));
console.log(catalogo.livros.length, 'títulos no catálogo');

Rodando uma pasta acima, com node livraria-aurora/scripts/estoque.mjs:

node:internal/fs/promises:640 return new FileHandle(await PromisePrototypeThen( ^

Error: ENOENT: no such file or directory, open ‘catalogo.json’ at async open (node:internal/fs/promises:640:25) at async readFile (node:internal/fs/promises:1287:14) at async file:///private/tmp/livraria-aurora/scripts/estoque.mjs:3:29 { errno: -2, code: ‘ENOENT’, syscall: ‘open’, path: ‘catalogo.json’ }

Node.js v24.16.0

Repare no campo path do fim: 'catalogo.json', sem pasta nenhuma. É a pista de que o caminho é relativo ao cwd. A correção é path.join(import.meta.dirname, '..', 'catalogo.json').

O segundo sabor é na escrita, e pega quase todo mundo: o Node não cria a pasta sozinho.

js
import { writeFile } from 'node:fs/promises';

const recibo = { numero: 1042, titulo: 'Torto Arado', total: 59.9 };

await writeFile('dados/recibos/1042.json', JSON.stringify(recibo, null, 2));
console.log('recibo salvo');
node:internal/fs/promises:640 return new FileHandle(await PromisePrototypeThen( ^

Error: ENOENT: no such file or directory, open ‘dados/recibos/1042.json’ at async open (node:internal/fs/promises:640:25) at async writeFile (node:internal/fs/promises:1257:14) at async file:///private/tmp/livraria-aurora/salvar-recibo.mjs:5:1 { errno: -2, code: ‘ENOENT’, syscall: ‘open’, path: ‘dados/recibos/1042.json’ }

Node.js v24.16.0

A solução é uma linha: mkdir com recursive: true, que cria a árvore inteira e não reclama se ela já existir.

js
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';

const recibo = { numero: 1042, titulo: 'Torto Arado', total: 59.9 };
const destino = path.join('dados', 'recibos', '1042.json');

await mkdir(path.dirname(destino), { recursive: true });
await writeFile(destino, JSON.stringify(recibo, null, 2));

console.log('recibo salvo em', path.resolve(destino));

Rodando duas vezes seguidas:

recibo salvo em /private/tmp/livraria-aurora/dados/recibos/1042.json recibo salvo em /private/tmp/livraria-aurora/dados/recibos/1042.json

Sem o recursive: true, a segunda execução morreria com EEXIST. Com ele, mkdir vira uma operação que você pode repetir à vontade — chame antes de todo writeFile que grava em subpasta e esqueça o assunto.

Um mini banco em JSON que sobrevive ao restart

Junte o que já apareceu e você tem persistência de verdade, sem instalar banco nenhum. Este arquivo guarda a estante da livraria e continua de onde parou a cada execução:

js
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import path from 'node:path';

const ARQUIVO = path.join(import.meta.dirname, 'dados', 'estante.json');

async function ler() {
  try {
    return JSON.parse(await readFile(ARQUIVO, 'utf8'));
  } catch (erro) {
    if (erro.code !== 'ENOENT') throw erro;
    return { livros: [] }; // primeira execução: ainda não existe arquivo
  }
}

async function salvar(estante) {
  await mkdir(path.dirname(ARQUIVO), { recursive: true });
  await writeFile(ARQUIVO, JSON.stringify(estante, null, 2) + '\n');
}

const titulo = process.argv.slice(2).join(' ');
const estante = await ler();

if (titulo) {
  estante.livros.push({ titulo, entrada: '2026-05-23' });
  await salvar(estante);
}

console.log(`${estante.livros.length} livro(s) na estante`);
for (const livro of estante.livros) console.log(' -', livro.titulo);

Três execuções, uma depois da outra:

bash
node estante.mjs "Torto Arado"
node estante.mjs "Vidas Secas"
node estante.mjs
1 livro(s) na estante - Torto Arado 2 livro(s) na estante - Torto Arado - Vidas Secas 2 livro(s) na estante - Torto Arado - Vidas Secas

O processo morreu três vezes e o dado ficou. O detalhe que faz isso funcionar está no catch: o ENOENT da primeira execução não é falha, é “ainda não tem nada” — e vira uma estante vazia. Qualquer outro erro é relançado, porque engolir EACCES só troca um problema visível por um silencioso.

O buraco dessa abordagem

writeFile grava por cima do arquivo antigo. Se o processo morrer no meio da gravação — deploy, Ctrl+C, falta de energia — sobra meio JSON no disco. Dá para simular cortando o arquivo pela metade:

bash
head -c 60 dados/estante.json > meio.json && mv meio.json dados/estante.json
node estante.mjs
<anonymous_script>:5 "

SyntaxError: Unterminated string in JSON at position 60 (line 5 column 8) at JSON.parse (<anonymous>) at ler (file:///private/tmp/livraria-aurora/estante.mjs:8:17) at async file:///private/tmp/livraria-aurora/estante.mjs:21:17

Node.js v24.16.0

O banco inteiro virou lixo. A defesa clássica cabe em duas linhas: grave num arquivo temporário e só então renomeie. O rename é atômico no sistema de arquivos — ou o nome aponta para o arquivo velho, ou para o novo, nunca para metade de um.

js
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import path from 'node:path';

const ARQUIVO = path.join(import.meta.dirname, 'dados', 'estante.json');

async function salvar(estante) {
  await mkdir(path.dirname(ARQUIVO), { recursive: true });
  const rascunho = `${ARQUIVO}.tmp`;
  await writeFile(rascunho, JSON.stringify(estante, null, 2) + '\n');
  await rename(rascunho, ARQUIVO); // troca instantânea: nunca existe meio arquivo
}

await salvar({ livros: [{ titulo: 'Torto Arado', entrada: '2026-05-23' }] });

const conteudo = await readFile(ARQUIVO, 'utf8');
console.log('lido de volta:', JSON.parse(conteudo).livros[0].titulo);
lido de volta: Torto Arado

Isso resolve a gravação interrompida, não a concorrência: dois processos escrevendo ao mesmo tempo continuam se atropelando. Quando chegar nesse ponto, o JSON cumpriu o papel dele e é hora de um banco de verdade.

Fechamento do dia: readdir filtrando por extensão

readdir lista o que existe numa pasta. E lista tudo: subpastas, arquivos ocultos, o .DS_Store que o Finder deixou para trás.

js
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';

const pasta = path.join(import.meta.dirname, 'dados', 'recibos');

const tudo = await readdir(pasta);
console.log('readdir cru ->', tudo);

const entradas = await readdir(pasta, { withFileTypes: true });
const recibos = entradas
  .filter((item) => item.isFile() && path.extname(item.name) === '.json')
  .map((item) => path.join(pasta, item.name));

console.log('só os .json ->', recibos.map((caminho) => path.basename(caminho)));

const lidos = await Promise.all(
  recibos.map(async (caminho) => JSON.parse(await readFile(caminho, 'utf8'))),
);

const total = lidos.reduce((soma, recibo) => soma + recibo.total, 0);
console.log(`fechamento do dia: ${lidos.length} vendas, R$ ${total.toFixed(2)}`);
readdir cru -> [ '.DS_Store', '1042.json', '1043.json', '1044.json', '2025', 'observacoes.txt' ] só os .json -> [ '1042.json', '1043.json', '1044.json' ] fechamento do dia: 3 vendas, R$ 192.30

Dois detalhes valem o hábito. withFileTypes: true faz o readdir devolver objetos com isFile() e isDirectory(), e assim você não precisa de um stat por item só para saber se 2025 é pasta. E readdir devolve nomes, não caminhos: sem o path.join, o readFile procuraria 1042.json no cwd e você cairia de novo no ENOENT da seção anterior.

As três leituras acontecem em paralelo dentro do Promise.all — com dez mil recibos isso derruba o disco, e aí você lê em lotes. O comportamento de Promise.all, allSettled e race é o que decide o que acontece se um dos arquivos estiver corrompido.

Arquivo grande: quando readFile deixa de servir

Até aqui, todo arquivo cabia na memória. A exportação anual de vendas da livraria não cabe. Antes de decidir como ler, pergunte o tamanho:

js
import { stat } from 'node:fs/promises';

const info = await stat('exportacao.csv');

console.log('é arquivo?  ->', info.isFile());
console.log('é pasta?    ->', info.isDirectory());
console.log('tamanho     ->', (info.size / 1024 / 1024).toFixed(1), 'MB');

if (info.size > 50 * 1024 * 1024) {
  console.log('grande demais para readFile: use stream');
}
é arquivo? -> true é pasta? -> false tamanho -> 300.0 MB grande demais para readFile: use stream

Existe até um teto absoluto: uma string em JavaScript não passa de meio gigabyte.

js
import { constants } from 'node:buffer';

const limite = constants.MAX_STRING_LENGTH;
console.log('maior string do V8 ->', limite, 'caracteres');
console.log('em MiB             ->', (limite / 1024 / 1024).toFixed(0));
maior string do V8 -> 536870888 caracteres em MiB -> 512

Passou disso, readFile com 'utf8' morre com Cannot create a string longer than 0x1fffffe8 characters e não tem contorno — 0x1fffffe8 é o mesmo 536.870.888 de cima, em hexadecimal. Mas o problema aparece muito antes do teto: o readFile já é caro bem antes de ser impossível. Vamos somar o faturamento do CSV de 300 MB (5.774.706 linhas) das duas maneiras. Primeiro, a ingênua:

js
import { readFile } from 'node:fs/promises';

const inicio = performance.now();

const texto = await readFile('exportacao.csv', 'utf8');
const linhas = texto.split('\n');
const total = linhas.reduce((soma, linha) => {
  const preco = linha.split(';')[3];
  return preco ? soma + Number(preco) : soma;
}, 0);

console.log('vendas      ->', linhas.length - 1);
console.log('faturamento -> R$', total.toFixed(2));
console.log('tempo       ->', (performance.now() - inicio).toFixed(0), 'ms');

Agora a mesma conta lendo em pedaços, linha a linha, com createReadStream e o módulo readline:

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

const inicio = performance.now();

const leitor = createInterface({
  input: createReadStream('exportacao.csv'),
  crlfDelay: Infinity,
});

let vendas = 0;
let total = 0;

for await (const linha of leitor) {
  const preco = linha.split(';')[3];
  if (!preco) continue;
  total += Number(preco);
  vendas += 1;
}

console.log('vendas      ->', vendas);
console.log('faturamento -> R$', total.toFixed(2));
console.log('tempo       ->', (performance.now() - inicio).toFixed(0), 'ms');

Para medir o pico de memória de verdade, quem conta é o sistema operacional, não o processo:

bash
/usr/bin/time -l node contar-readfile.mjs
/usr/bin/time -l node contar-stream.mjs
vendas -> 5774706 faturamento -> R$ 370158654.59 tempo -> 1771 ms 1.82 real 1.47 user 0.34 sys 1042792448 maximum resident set size

vendas -> 5774706 faturamento -> R$ 370158654.59 tempo -> 1579 ms 1.62 real 1.53 user 0.12 sys 117538816 maximum resident set size

O resultado é idêntico, o custo não. Média de 5 execuções num MacBook, Node 24.16.0, com o arquivo já no cache do sistema:

abordagem pico de memória tempo médio teto de tamanho
readFile + split 936 MB 1921 ms 512 MiB de texto
createReadStream + readline 112 MB 1581 ms nenhum

Oito vezes menos memória para ler o mesmo arquivo, e nem foi mais lento. O motivo é simples: readFile precisa do arquivo inteiro em RAM, mais a cópia em string UTF-16, mais os quase seis milhões de pedaços que o split cria. O stream mantém só um pedaço por vez na memória e joga fora o que já processou.

Nos 300 MB da livraria, a diferença é entre um processo confortável e um que morre num container com 512 MB de limite. A linha de corte que uso na prática: até uns 10 MB, readFile e siga a vida; acima disso, stream.

O que vem depois

Você já sabe guardar dado em disco. O passo seguinte da trilha de Node é tirar do código o que não pertence a ele: senha, chave de API e caminho de pasta viram variáveis de ambiente, lidas de um .env que nunca entra no Git. Depois disso, esses arquivos passam a ser servidos por uma API — e o guia completo de Node mostra a ordem inteira até lá.

  • node
  • fs
  • path
  • arquivo
  • json
  • async

Perguntas frequentes

Ainda vale usar readFileSync em algum lugar?
Vale em script de linha de comando, em migração e na inicialização do processo, quando ninguém está esperando resposta. Dentro de rota de API, não: a versão síncrona trava a única thread que atende todo mundo.
Como verificar se o arquivo existe antes de ler?
O jeito mais seguro é não verificar. Entre o access e o readFile o arquivo pode sumir, e você tem que tratar o ENOENT de qualquer forma. Faça a leitura direto dentro de try/catch e trate erro.code === 'ENOENT'.
Dá para o Node avisar quando um arquivo mudar?
Dá, com fs.watch, mas o comportamento muda entre Windows, Linux e macOS, e é comum receber dois eventos para uma única gravação. Para recarregar código em desenvolvimento, prefira rodar node --watch.
Onde o Node grava, se eu passar um caminho relativo?
A partir de process.cwd(), a pasta em que o comando node foi digitado, e não a pasta do arquivo .js. É por isso que o mesmo script funciona quando você roda de dentro do projeto e falha quando o cron roda de outro lugar.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

Todo o código deste artigo foi executado em Node 24.16.0, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. Node.js — File system (fs/promises) — nodejs.org
  2. Node.js — Path — nodejs.org

Continue por aqui