Pular para o conteúdo
Node.js

Node.js: exportando dados do MySQL para Excel

Ilustração colorida de um unicórnio de crina arco-íris e um golfinho mágico despejando cristais de dados sobre uma grande planilha de linhas e colunas desenhadas em luz, com uma coruja organizando as colunas com uma varinha

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?
Porque o 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?
É um campo 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?
A 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?
Não com o 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?
Porque um arquivo gravado pela metade tem cara de planilha boa: aparece na pasta, com nome certo e data de hoje. Se a exportação falhar no meio e a planilha de ontem continuar ali, alguém vai abrir o arquivo velho achando que é o novo. Apagar no catch deixa a pasta vazia, que é um sinal honesto de que deu errado.
Como guardar a senha do banco sem colocar no código?
Por variável de ambiente, lida com 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