Node.js: exportando dados do MySQL para Excel
Olá meus Unicórnios! 🦄✨
Existe um pedido que chega em todo projeto, mais cedo ou mais tarde, e quase sempre pelo WhatsApp: "dá para me mandar isso numa planilha?" 😅
E a gente sabe que dá. É só uma consulta no banco e um arquivo, certo? Foi exatamente o que eu pensei na primeira vez. Escrevi umas trinta linhas, rodei, abri o arquivo no Excel todo orgulhosa e... as datas estavam todas com a hora trocada, e uma coluna inteira dizia [object Object]. 🙃
Este artigo é o caminho até a planilha que presta: como transformar uma consulta MySQL num arquivo .xlsx de verdade com Node.js, e principalmente como não estragar os dados no meio do caminho. O script inteiro tem menos de cem linhas e duas dependências.
📦 As duas bibliotecas, e só elas
São duas mesmo, e cada uma faz uma ponta do trabalho:
npm install mysql2 exceljs
A mysql2 conversa com o banco. A exceljs escreve o arquivo .xlsx: o formato moderno do Excel, aquele que é um zip cheio de XML por dentro. A vantagem dela é não precisar do Excel instalado na máquina: o arquivo sai pronto de um servidor Linux pelado, se for o caso.
🔌 Conectando sem deixar senha no código
Primeira parte do script. Repare que não existe nenhuma senha escrita aqui dentro:
async function conectar() {
return await mysql.createConnection({
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER,
password: process.env.DB_SENHA,
database: process.env.DB_NOME,
dateStrings: true,
});
}
Tudo vem de process.env, que é como o Node lê as variáveis de ambiente: valores que você entrega na hora de rodar o programa, em vez de deixar gravados no arquivo. Assim o script pode ser copiado, versionado e mandado para um colega sem levar a senha do banco junto. 🔐
Na hora de rodar, os valores vão na frente do comando:
DB_PORT=3399 DB_USER=relatorios DB_SENHA=a_senha DB_NOME=loja node exportar.js
Agora aquela linha, a dateStrings: true. Ela parece detalhe de configuração e é a coisa mais importante deste artigo inteiro. 🙏
🕐 A primeira mentira: a data que muda sozinha
Por padrão, o mysql2 pega um campo DATETIME e faz um favor que ninguém pediu: converte para um objeto Date do JavaScript, usando o fuso horário do computador que está rodando o script.
O problema é que o DATETIME do MySQL não tem fuso nenhum. Ele é só "16 de janeiro de 2026, às 10h". Quando o driver resolve interpretar isso num fuso e a planilha resolve mostrar noutro, a hora escorrega. E quando o horário é perto da meia-noite, escorrega o dia junto.
Foi o que apareceu na planilha do jeito ingênuo, sem a configuração:
id | total | criado_em
1 | 149.90 | Fri Jan 16 2026 10:00:00 GMT-0300 (Horário Padrão de Brasília)
2 | 89.00 | Tue Feb 03 2026 11:20:00 GMT-0300 (Horário Padrão de Brasília)
3 | 320.50 | Sun Mar 01 2026 16:10:00 GMT-0300 (Horário Padrão de Brasília)
Olha o tamanho da encrenca: além do risco de fuso, a data virou texto em inglês com nome de dia da semana e tudo. Ninguém vai conseguir ordenar por data, filtrar por mês ou fazer conta com isso. É texto, não é data.
Com dateStrings: true, o driver para de "ajudar" e entrega a data como texto, exatamente como está gravada no banco:
id | total | criado_em
1 | 149.90 | 2026-01-16 10:00:00
2 | 89.00 | 2026-02-03 11:20:00
3 | 320.50 | 2026-03-01 16:10:00
Uma linha de configuração, e a coluna volta a fazer sentido. 🎉
🧱 A segunda mentira: as colunas que viram [object Object]
A outra armadilha aparece quando a tabela tem campo BLOB ou JSON. O driver entrega esses dois como objetos do JavaScript, e a planilha não faz ideia do que fazer com objeto: ela chama o toString() e o resultado é lixo.
Na exportação ingênua, a coluna do comprovante saiu assim:
id | itens | comprovante
1 | {"cor":"azul","qtd":2} | {"type":"Buffer","data":[137,80,78,71]}
2 | {"cor":"verde","qtd":1} |
3 | | {"type":"Buffer","data":[255,216,255,224]}
Esse {"type":"Buffer","data":[...]} é a cara do Buffer, que é como o Node representa um monte de bytes crus. É o que o MySQL devolve para qualquer campo binário.
A solução é uma função de três if, que olha o valor antes de mandar para a célula. Esta é a parte do script que eu mais gosto, porque ela é bobinha e resolve os dois casos de uma vez:
function valorDaCelula(valor) {
if (valor === null || valor === undefined) {
return null;
}
// Campo BLOB chega como Buffer. Jogado direto na planilha ele vira
// "[object Object]" em toda a coluna.
if (Buffer.isBuffer(valor)) {
return '0x' + valor.toString('hex');
}
// Campo JSON chega como objeto ja pronto, e daria o mesmo
// "[object Object]". Aqui ele volta a ser texto legivel.
if (typeof valor === 'object') {
return JSON.stringify(valor);
}
return valor;
}
Repare na ordem dos dois últimos if, que é onde mora o detalhe cruel: um Buffer também é um objeto, então typeof valor === 'object' é verdadeiro para ele também. Se você trocar os dois de lugar, todo campo binário cai no JSON.stringify e você recebe de volta aquele mesmo {"type":"Buffer","data":[...]} que estava tentando consertar. 😳
O Buffer.isBuffer() tem que vir antes. É uma linha trocada de lugar e a correção inteira vai por água abaixo, sem erro nenhum aparecer.
📊 Montando a planilha
Com os valores já tratados, escrever o arquivo é a parte tranquila:
async function gerarPlanilha(linhas, arquivo) {
const planilha = new ExcelJS.Workbook();
const aba = planilha.addWorksheet('Pedidos');
const colunas = Object.keys(linhas[0]);
aba.columns = colunas.map(function (nome) {
return { header: nome, key: nome, width: 20 };
});
aba.getRow(1).font = { bold: true };
// Congela a primeira linha: rolando a planilha, os titulos ficam na tela.
aba.views = [{ state: 'frozen', ySplit: 1 }];
for (const linha of linhas) {
const celulas = [];
for (const nome of colunas) {
celulas.push(valorDaCelula(linha[nome]));
}
aba.addRow(celulas);
}
await planilha.xlsx.writeFile(arquivo);
}
Os nomes das colunas saem sozinhos do Object.keys(linhas[0]), que lê as chaves da primeira linha do resultado. Ou seja: você mexe no SELECT e a planilha acompanha, sem precisar editar lista de cabeçalho nenhuma.
As três linhas de enfeite valem muito pelo que custam. O font = { bold: true } deixa o cabeçalho em negrito; a largura de 20 evita aquela coluna espremida mostrando ####; e o frozen com ySplit: 1 congela a primeira linha, então quem rolar a planilha continua vendo o nome das colunas. Quem recebe o arquivo repara nisso. 😊
🧹 Quando dá errado, apague a planilha
Falta o main(), e é nele que mora a parte que eu acho mais importante do script depois da conversão de tipos:
async function main() {
let conexao;
try {
conexao = await conectar();
console.log('Conectado ao banco.');
const linhas = await buscarPedidos(conexao);
console.log(linhas.length + ' linha(s) encontrada(s).');
if (linhas.length === 0) {
console.log('Nada para exportar.');
return;
}
await gerarPlanilha(linhas, ARQUIVO);
console.log('Planilha gravada em: ' + ARQUIVO);
} catch (erro) {
console.error('Falhou: ' + erro.message);
// Se a gravacao parou no meio, sobra um .xlsx quebrado no disco com
// cara de planilha boa. Apagar aqui evita alguem abrir o arquivo de
// ontem achando que e o de hoje.
if (fs.existsSync(ARQUIVO)) {
fs.unlinkSync(ARQUIVO);
console.error('Planilha incompleta apagada.');
}
process.exitCode = 1;
} finally {
if (conexao) {
await conexao.end();
}
}
}
Aquele unlinkSync dentro do catch parece exagero, mas ele evita o pior tipo de erro que existe: o silencioso. 🤫
Pense no que acontece sem ele. A exportação de hoje quebra no meio, e a planilha de ontem continua na pasta com o mesmo nome. Alguém abre, vê dados, confia. Arquivo velho com cara de arquivo novo é pior que arquivo nenhum, porque o arquivo que não existe pelo menos avisa que deu errado.
É o mesmo raciocínio do finally logo abaixo: ele fecha a conexão dando certo ou dando errado. Sem isso, um erro no meio do caminho deixa a conexão pendurada e o programa não termina nunca. Fica ali, parado, esperando nada.
Vale ver o que sai quando dá errado de propósito. Mandei o script procurar uma base que não existe:
Falhou: Unknown database 'naoexiste'
Planilha incompleta apagada.
Mensagem clara em português, planilha velha removida, e o programa sai com código 1. Esse código é o que permite mandar o script rodar sozinho depois e alguém saber que falhou, em vez de descobrir na segunda-feira.
▶️ Rodando
Com o banco de pé e as variáveis preenchidas, é isto:
Conectado ao banco.
3 linha(s) encontrada(s).
Planilha gravada em: ...\pedidos.xlsx
E a planilha que sai, com as colunas já tratadas:
id | cliente_id | total | itens | comprovante | criado_em
1 | 1 | 149.90 | {"cor":"azul","qtd":2} | 0x89504e47 | 2026-01-16 10:00:00
2 | 2 | 89.00 | {"cor":"verde","qtd":1} | | 2026-02-03 11:20:00
3 | 1 | 320.50 | | 0xffd8ffe0 | 2026-03-01 16:10:00
Data legível e ordenável, JSON que dá para ler, binário virado hexadecimal e as células vazias realmente vazias, e não um "null" escrito por extenso, que é o que acontece quando a gente esquece o primeiro if da valorDaCelula. 🌟
🐘 E quando a tabela é enorme?
O script acima carrega o resultado inteiro na memória antes de escrever. Para as tabelas do dia a dia isso é perfeito e mantém o código simples de ler, que era o objetivo aqui.
Mas se a sua tabela tem centenas de milhares de linhas, a memória do Node vai reclamar. Aí a exceljs tem uma versão que escreve em disco conforme as linhas chegam, em vez de montar tudo antes:
const planilha = new ExcelJS.stream.xlsx.WorkbookWriter({ filename: arquivo });
const aba = planilha.addWorksheet('Pedidos');
// cada linha vai para o disco na hora, com .commit()
aba.addRow(celulas).commit();
aba.commit();
await planilha.commit();
A diferença é o .commit(): ele é o "pode gravar esta linha e esquecer dela". Só não vá inventar de ler uma linha já gravada depois, porque ela não está mais na memória. 😉
E guarde um número: o Excel para em 1.048.576 linhas por aba. Passou disso, o assunto deixou de ser planilha e virou banco de dados de novo.
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Perguntas frequentes
Por que a data sai errada na planilha exportada do MySQL?
mysql2, por padrão, converte DATETIME em objeto Date do JavaScript usando o fuso do computador que roda o script. Um pedido gravado às 21h aparece com outra hora, e às vezes em outro dia. A correção é uma linha na configuração da conexão: dateStrings: true, que faz a data chegar como texto igualzinho ao que está no banco.O que significa {"type":"Buffer","data":[...]} na minha planilha?
BLOB ou BINARY que foi jogado direto na célula. O driver entrega esses campos como Buffer, e o Excel não sabe o que fazer com isso. Converta antes de gravar: valor.toString('hex') para ver o conteúdo em hexadecimal, ou valor.toString('utf8') se você sabe que ali dentro é texto.Qual biblioteca usar para gerar .xlsx em Node.js?
exceljs. Ela gera o formato .xlsx de verdade, com negrito no cabeçalho, largura de coluna e painel congelado, sem precisar do Excel instalado na máquina. Junto com a mysql2 são as duas únicas dependências do script deste artigo.Posso exportar uma tabela com milhões de linhas desse jeito?
addWorksheet comum, porque ele monta a planilha inteira na memória antes de gravar. Para tabelas grandes a exceljs tem o ExcelJS.stream.xlsx.WorkbookWriter, que escreve em disco conforme as linhas chegam. Vale lembrar também que o próprio Excel para em 1.048.576 linhas por aba.Por que apagar a planilha dentro do catch?
catch deixa a pasta vazia, que é um sinal honesto de que deu errado.Como guardar a senha do banco sem colocar no código?
process.env. O script não tem senha nenhuma escrita dentro dele: os valores entram na hora de rodar, e assim o arquivo pode ser copiado, versionado e mandado para alguém sem levar credencial junto.Leia também
API Mágica: CEP e Pix de graça, sem cartão
Lancei a API Mágica: CEP, QR Code Pix, geradores e mais, de graça. Veja como consultar e gerar com Node.js em poucas linhas.
Resend: enviando e recebendo e-mails com Node.js
Tutorial da Resend com Node.js: criar a chave, enviar com fetch, verificar o domínio e receber e-mails por webhook conferindo a assinatura.
Node.js: testando scripts de scraping no ScrapingCourse
O ScrapingCourse é um site feito para treinar scraping. Cinco desafios dele em Node.js, e a armadilha real que cada um esconde.