Cómo leer CSV en Node.js con csv-parse y PapaParse, y qué hacer cuando el archivo es tan grande que no cabe en memoria.
Los CSV están en todos lados: el reporte que baja el contador del banco, la exportación de clientes que te manda otro sistema, el archivo que un usuario sube a tu aplicación. Son simples de abrir, pero leerlos bien tiene sus trampas. Comillas dentro de los campos, saltos de línea en medio de una celda, puntos y comas en lugar de comas, y de repente un archivo de 500 MB.
Aquí vemos cómo leer CSV en Node.js con las dos librerías más conocidas, csv-parse y PapaParse, y cómo procesar un archivo de un millón de filas con streams, sin que tu servidor se quede sin memoria. Si lo que te mandan es un .xlsx y no un CSV, mira primero cómo leer y escribir Excel en Node.js con ExcelJS.
Probé todo el código con Node.js 22, csv-parse 7.0.3 y PapaParse 5.7.0. Uso módulos ES, así que los archivos terminan en .mjs (o puedes poner "type": "module" en tu package.json).
Instalar csv-parse y PapaParse
mkdir csv-demo cd csv-demo npm init -y npm install csv-parse papaparse
No necesitas las dos en tu proyecto. Las instalo juntas para compararlas.
¿csv-parse o PapaParse?
Las dos leen bien un CSV normal. La diferencia está en cómo se portan cuando algo sale mal y en dónde las puedes usar.
- csv-parse está pensada para Node y trabaja con streams de forma natural. Es estricta: si una fila trae más o menos columnas que el encabezado, lanza un error y se detiene.
- PapaParse funciona en Node y también en el navegador. Detecta sola el delimitador, quita el BOM y no se detiene ante filas raras: te deja los problemas en una lista de errores para que decidas qué hacer.
Yo uso csv-parse en el servidor cuando quiero que un archivo malo falle rápido, y PapaParse cuando el mismo código tiene que correr en el navegador o cuando el archivo viene de usuarios y no quiero que un renglón roto tire todo.
Leer un CSV chico con csv-parse
Para las pruebas crea un archivo chico.csv. Trae a propósito una celda con comillas y otra con un salto de línea:
id,nombre,nota 1,Ana,"Dijo ""hola"", y se fue" 2,Luis,"Línea 1 Línea 2"
Ahora el código, en csv-parse-simple.mjs:
import { readFileSync } from 'node:fs';
import { parse } from 'csv-parse/sync';
const texto = readFileSync('chico.csv', 'utf8');
const filas = parse(texto, {
columns: true, // usa la primera fila como nombres
skip_empty_lines: true,
trim: true,
});
console.log(filas);
Con columns: true cada fila llega como un objeto, con los nombres del encabezado como llaves. Sin esa opción recibes arreglos de arreglos. Fíjate que importamos de csv-parse/sync: esa versión devuelve todo de golpe, y sirve para archivos chicos.
Leer el mismo CSV con PapaParse
import { readFileSync } from 'node:fs';
import Papa from 'papaparse';
const texto = readFileSync('chico.csv', 'utf8');
const resultado = Papa.parse(texto, {
header: true,
skipEmptyLines: true,
});
console.log(resultado.data);
console.log('errores:', resultado.errors);
Aquí la opción se llama header: true. PapaParse no devuelve las filas directo, sino un objeto: los datos van en data y los problemas en errors.
Leer un CSV grande con streams
Hasta aquí todo cabe en memoria sin problema. Para probar un caso real genero un archivo de un millón de ventas, con este script generar.mjs:
import { createWriteStream } from 'node:fs';
const salida = createWriteStream('ventas.csv');
salida.write('id,cliente,producto,cantidad,precio\n');
const productos = ['Teclado', 'Mouse', 'Monitor', 'Cable USB', 'Laptop'];
const TOTAL = 1_000_000;
for (let i = 1; i <= TOTAL; i++) {
const producto = productos[i % productos.length];
const linea = `${i},Cliente ${i % 5000},${producto},${(i % 9) + 1},${(100 + (i % 900)).toFixed(2)}\n`;
if (!salida.write(linea)) {
await new Promise((r) => salida.once('drain', r));
}
}
salida.end();
console.log(`Archivo generado con ${TOTAL} filas`);
El archivo pesa unos 35 MB. No es enorme, pero alcanza para ver el problema.
Primero, cargarlo todo en memoria
Este es el camino que sale por instinto: leer el archivo completo y convertirlo a un arreglo.
import { readFileSync } from 'node:fs';
import { parse } from 'csv-parse/sync';
const filas = parse(readFileSync('ventas.csv', 'utf8'), { columns: true });
let total = 0;
for (const f of filas) total += Number(f.cantidad) * Number(f.precio);
console.log('Filas:', filas.length);
console.log('Total vendido:', total.toFixed(2));
console.log('Memoria:', Math.round(process.memoryUsage().rss / 1024 / 1024), 'MB');
Funciona, pero fíjate en cuánta memoria usa. Los números están más abajo, en el resultado.
Ahora con streams en csv-parse
Con un stream, Node lee el archivo en pedazos y entrega una fila a la vez. Nunca tienes el archivo completo en memoria:
import { createReadStream } from 'node:fs';
import { parse } from 'csv-parse';
const parser = createReadStream('ventas.csv').pipe(
parse({ columns: true })
);
let filas = 0;
let total = 0;
for await (const f of parser) {
filas++;
total += Number(f.cantidad) * Number(f.precio);
}
console.log('Filas:', filas);
console.log('Total vendido:', total.toFixed(2));
console.log('Memoria:', Math.round(process.memoryUsage().rss / 1024 / 1024), 'MB');
Ahora el import es de csv-parse a secas, sin /sync. El for await te da cada fila y, si adentro haces algo asíncrono, el stream espera a que termines antes de pedir la siguiente. Eso es lo que evita que se te llene la memoria.
Y con streams en PapaParse
PapaParse acepta directamente un stream de Node. En lugar de un for await, usa la función step, que se ejecuta una vez por fila:
import { createReadStream } from 'node:fs';
import Papa from 'papaparse';
let filas = 0;
let total = 0;
Papa.parse(createReadStream('ventas.csv'), {
header: true,
skipEmptyLines: true,
step: (resultado) => {
const f = resultado.data;
filas++;
total += Number(f.cantidad) * Number(f.precio);
},
complete: () => {
console.log('Filas:', filas);
console.log('Total vendido:', total.toFixed(2));
console.log('Memoria:', Math.round(process.memoryUsage().rss / 1024 / 1024), 'MB');
},
error: (err) => console.error('Error:', err.message),
});
Si dentro de step necesitas hacer algo asíncrono, como guardar en la base de datos, PapaParse no espera solo. Tienes que pausarlo tú con el segundo parámetro y reanudarlo cuando termines:
javascript
step: (resultado, parser) => {
parser.pause();
guardar(resultado.data).then(() => parser.resume());
},
Eso es más trabajo que el for await de csv-parse, y es una de las razones por las que para el servidor me quedo con csv-parse.
Guardar en la base de datos por lotes
Leer un millón de filas no sirve de mucho si después haces un millón de INSERT. Lo normal es juntar las filas en un lote y guardarlo cada cierto número. Este ejemplo también convierte los números, porque por defecto todo llega como texto:
import { createReadStream } from 'node:fs';
import { parse } from 'csv-parse';
const TAMANO_LOTE = 10_000;
// Aquí iría tu INSERT a MySQL, PostgreSQL o SQLite.
// Para probar, solo simulamos que guarda.
async function guardarLote(lote) {
await new Promise((r) => setImmediate(r));
}
const parser = createReadStream('ventas.csv').pipe(
parse({
columns: true,
cast: (valor, contexto) => {
if (contexto.header) return valor; // no convertir los encabezados
if (['id', 'cantidad', 'precio'].includes(contexto.column)) {
return Number(valor);
}
return valor;
},
})
);
let lote = [];
let lotes = 0;
let filas = 0;
for await (const fila of parser) {
lote.push(fila);
filas++;
if (lote.length === TAMANO_LOTE) {
await guardarLote(lote);
lotes++;
lote = [];
}
}
if (lote.length > 0) {
await guardarLote(lote);
lotes++;
}
console.log(`Filas leídas: ${filas}`);
console.log(`Lotes guardados: ${lotes}`);
console.log('Memoria:', Math.round(process.memoryUsage().rss / 1024 / 1024), 'MB');
La función guardarLote es la que cambias por tu consulta real. El if del final es para las filas que sobran: si el archivo trae 1,000,005 filas, las últimas 5 también se tienen que guardar.
Resultado: leer CSV en Node.js con un millón de filas
Los tres programas leyeron el mismo archivo de un millón de filas y calcularon el mismo total. Esto fue lo que salió en mi máquina:
== Todo en memoria (csv-parse/sync) == Filas: 1000000 Total vendido: 2753966164.00 Memoria: 373 MB == Stream con csv-parse == Filas: 1000000 Total vendido: 2753966164.00 Memoria: 69 MB == Stream con PapaParse == Filas: 1000000 Total vendido: 2753966164.00 Memoria: 83 MB
Un archivo de 35 MB ocupó 373 MB al cargarlo completo, unas diez veces su tamaño, porque cada fila se vuelve un objeto con sus propias cadenas. Con streams la memoria se quedó entre 69 y 83 MB, y no crece si el archivo crece.
Para ver la diferencia en un caso extremo, limité la memoria de Node a 100 MB con --max-old-space-size=100, como si fuera un servidor chico. La versión que carga todo se cayó con este error:
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
La versión con stream leyó el millón de filas completo sin problema, con unos 70 MB.
En tiempo, tres corridas de cada una dieron entre 3.1 y 3.4 segundos con csv-parse y entre 1.1 y 1.3 con PapaParse. Esos números dependen de tu equipo y de las opciones que uses (la versión por lotes con conversión de tipos tardó un poco más), así que tómalos como una comparación entre ellas y no como un tiempo que vayas a ver igual.
Errores comunes
Estos son los que más se repiten al leer CSV en Node.js.
1. Error “Invalid Record Length” con un CSV de Excel
Si tu Excel está configurado en español, al guardar como CSV usa punto y coma como separador, y los decimales vienen con coma. csv-parse espera comas y se confunde:
CSV_RECORD_INCONSISTENT_COLUMNS - Invalid Record Length: columns length is 1, got 2 on line 2
La solución es decirle cuál es el separador:
javascript
parse(texto, { columns: true, delimiter: ';' });
PapaParse detecta el delimitador sola, así que ahí no hace falta.
2. La primera columna “no existe” (el BOM)
Excel suele agregar al inicio del archivo unos bytes invisibles llamados BOM. Si no los quitas, la primera columna se llama id en lugar de id. Se ve igual al imprimirla, pero fila.id te da undefined, y es difícil de encontrar. Con csv-parse, agrega bom: true:
javascript
parse(texto, { columns: true, delimiter: ';', bom: true });
PapaParse lo quita sola.
3. Los acentos salen como �
Si ves Jos� en lugar de José, el archivo no está en UTF-8, casi siempre es Latin1 (los CSV viejos de Windows). Dile a Node cómo leerlo:
javascript
readFileSync('archivo.csv', 'latin1');
Con un stream es la misma idea: createReadStream('archivo.csv', { encoding: 'latin1' }).
4. Todo llega como texto, y los decimales con coma no se convierten
Un CSV no tiene tipos: 42 y 1,50 llegan como cadenas. Para los números normales te sirve cast, como en el ejemplo de lotes. Pero ojo con los decimales con coma:
javascript
Number('1,50'); // NaN
Number('1,50'.replace(',', '.')); // 1.5
Si tu archivo trae decimales con coma, reemplázala antes de convertir.
5. Filas con más o menos columnas
csv-parse se detiene en la primera fila rara:
CSV_RECORD_INCONSISTENT_COLUMNS - Invalid Record Length: columns length is 2, got 3 on line 3
Puedes dejarla pasar con relax_column_count: true, pero cuidado: la columna de sobra se pierde en silencio. Con un archivo que tiene la fila 2,Luis,extra, el resultado fue { id: '2', nombre: 'Luis' }, sin el extra. Úsala solo si de verdad no te importa ese dato.
PapaParse no se detiene. Te deja los problemas en errors, con el número de fila (empieza en 0):
[ 'TooManyFields fila 1', 'TooFewFields fila 2' ]
6. Un INSERT por cada fila
Con un stream es tentador guardar cada fila en cuanto llega. Con un millón de filas son un millón de consultas a la base. Junta las filas en lotes, como en el ejemplo de arriba.
(Si tienes alguna anécdota real con un CSV problemático en producción, este es el lugar para contarla en uno o dos párrafos. Por ejemplo, un archivo que llegó con el separador cambiado o uno que tumbó el servidor por memoria.)
Recomendaciones
- Si el archivo es chico y lo controlas,
csv-parse/synces lo más sencillo. Los streams valen la pena cuando el archivo puede crecer o cuando lo suben los usuarios. - Cuando vayas a leer CSV en Node.js que vienen de usuarios, revisa que los encabezados sean los que esperas antes de guardar nada. Un archivo con las columnas cambiadas puede llenar tu base de datos de basura.
- Guarda los errores con su número de fila. Si un usuario sube un archivo con tres filas malas, es mucho más útil decirle cuáles son que rechazarle todo el archivo.
- Pon un límite al tamaño de los archivos que aceptas, aunque uses streams.
- Empieza con lotes de unos miles de filas y ajusta según tu base de datos.
Escribir CSV, que es el camino contrario, lo dejo para otro artículo.
