Tier 2 — Intermediate

CSV Data Analyser

Parse CSV files, compute column statistics, filter rows, and export results — all with zero dependencies.

🐍 Python 🟨 JavaScript

1. The Problem

Build a CSV analyser: read any CSV file, detect column types (number vs string), compute stats for numeric columns (min, max, mean, median, stddev), filter rows by a condition, and export the result. Zero npm dependencies — only Node.js built-ins.

Why this project? Manual CSV parsing, type detection, statistical functions, filter expressions, and stdout/file output — the core toolkit behind every data pipeline.

2. How to Think About This

Parse CSV in two passes: first line is headers, remaining lines are data rows. Split on commas (handle quoted values). Detect column type by checking if every non-empty value parses as a number. Stats computed with reduce. Filter rows by evaluating a JS expression string against each row object using new Function.

3. The Build

csv.mjs
import { readFileSync, writeFileSync } from "fs";

// ── Parser ─────────────────────────────────────────────────────────────────
export function parseCSV(text) {
  const lines = text.trim().split(/
?
/);
  if (lines.length < 2) throw new Error("CSV must have a header row and at least one data row");
  const headers = splitCSVLine(lines[0]);
  const rows = lines.slice(1).map(line => {
    const vals = splitCSVLine(line);
    return Object.fromEntries(headers.map((h, i) => [h, vals[i] ?? ""]));
  });
  return { headers, rows };
}

function splitCSVLine(line) {
  const result = [];
  let current = "", inQuotes = false;
  for (const ch of line) {
    if (ch === '"') { inQuotes = !inQuotes; continue; }
    if (ch === "," && !inQuotes) { result.push(current.trim()); current = ""; continue; }
    current += ch;
  }
  result.push(current.trim());
  return result;
}

// ── Type detection ─────────────────────────────────────────────────────────
export function detectTypes(headers, rows) {
  return Object.fromEntries(headers.map(h => {
    const vals = rows.map(r => r[h]).filter(v => v !== "");
    const isNum = vals.length > 0 && vals.every(v => !isNaN(Number(v)));
    return [h, isNum ? "number" : "string"];
  }));
}

// ── Statistics ─────────────────────────────────────────────────────────────
export function stats(values) {
  const nums = values.map(Number).filter(n => !isNaN(n));
  if (!nums.length) return null;
  const sum  = nums.reduce((a, b) => a + b, 0);
  const mean = sum / nums.length;
  const sorted = [...nums].sort((a, b) => a - b);
  const mid  = Math.floor(sorted.length / 2);
  const median = sorted.length % 2 ? sorted[mid] : (sorted[mid-1] + sorted[mid]) / 2;
  const variance = nums.reduce((a, b) => a + (b - mean) ** 2, 0) / nums.length;
  return {
    count:  nums.length,
    min:    +Math.min(...nums).toFixed(4),
    max:    +Math.max(...nums).toFixed(4),
    mean:   +mean.toFixed(4),
    median: +median.toFixed(4),
    stddev: +Math.sqrt(variance).toFixed(4),
  };
}

// ── Filter ─────────────────────────────────────────────────────────────────
export function filterRows(rows, expression) {
  // expression: "age > 30 && city === 'Mumbai'"
  // Each row's keys become variables via destructuring
  const fn = new Function(...Object.keys(rows[0]), `return ${expression}`);
  return rows.filter(row => {
    try { return fn(...Object.values(row)); } catch { return false; }
  });
}

// ── CLI ─────────────────────────────────────────────────────────────────────
const [,, file, ...flags] = process.argv;
if (!file) {
  console.error("Usage: node csv.mjs <file.csv> [--filter 'expr'] [--col colname] [--out out.csv]");
  process.exit(1);
}

const text = readFileSync(file, "utf-8");
const { headers, rows } = parseCSV(text);
const types = detectTypes(headers, rows);

const filterIdx = flags.indexOf("--filter");
const colIdx    = flags.indexOf("--col");
const outIdx    = flags.indexOf("--out");
const filterExpr = filterIdx >= 0 ? flags[filterIdx + 1] : null;
const targetCol  = colIdx    >= 0 ? flags[colIdx + 1]    : null;
const outFile    = outIdx    >= 0 ? flags[outIdx + 1]    : null;

let working = filterExpr ? filterRows(rows, filterExpr) : rows;

console.log(`
File: ${file}  (${rows.length} rows, ${headers.length} columns)`);
if (filterExpr) console.log(`Filter "${filterExpr}": ${working.length} rows match`);

const numCols = headers.filter(h => types[h] === "number" && (!targetCol || h === targetCol));
if (numCols.length) {
  console.log("
Numeric column statistics:");
  numCols.forEach(col => {
    const s = stats(working.map(r => r[col]));
    if (!s) return;
    console.log(`
  ${col}`);
    console.log(`    count: ${s.count}  min: ${s.min}  max: ${s.max}`);
    console.log(`    mean:  ${s.mean}   median: ${s.median}  stddev: ${s.stddev}`);
  });
}

if (outFile) {
  const csv = [headers.join(","), ...working.map(r => headers.map(h => r[h]).join(","))].join("
");
  writeFileSync(outFile, csv);
  console.log(`
Exported ${working.length} rows → ${outFile}`);
}

4. Test & Prove

import { parseCSV, detectTypes, stats, filterRows } from "./csv.mjs";
let p=0,f=0;
function test(d,fn){try{fn();console.log("  ✓",d);p++;}catch(e){console.log("  ✗",d,"—",e.message);f++;}}
const CSV = "name,age,city
Alice,30,Mumbai
Bob,25,Delhi
Carol,35,Mumbai";
const { headers, rows } = parseCSV(CSV);
test("headers parsed",          () => { if(headers.join()!=="name,age,city") throw new Error(); });
test("row count",               () => { if(rows.length!==3) throw new Error(rows.length); });
test("age detected as number",  () => { if(detectTypes(headers,rows).age!=="number") throw new Error(); });
test("name detected as string", () => { if(detectTypes(headers,rows).name!=="string") throw new Error(); });
test("stats mean",              () => { const s=stats(rows.map(r=>r.age)); if(Math.abs(s.mean-30)>0.01) throw new Error(s.mean); });
test("stats min/max",           () => { const s=stats(rows.map(r=>r.age)); if(s.min!==25||s.max!==35) throw new Error(); });
test("filter works",            () => { const f=filterRows(rows,"age > 28"); if(f.length!==2) throw new Error(f.length); });
test("filter no match",         () => { const f=filterRows(rows,"age > 100"); if(f.length!==0) throw new Error(); });
console.log(`
${p} passed, ${f} failed`);

5. The Interface

node csv.mjs data.csv
node csv.mjs data.csv --filter "age > 30"
node csv.mjs data.csv --col salary --filter "dept === 'Engineering'"
node csv.mjs data.csv --filter "city === 'Mumbai'" --out mumbai.csv

6. Run It

# Create a test file:
echo "name,age,score
Alice,30,95
Bob,25,87
Carol,35,72" > test.csv
node csv.mjs test.csv
node csv.test.mjs

🎯 Try this next

  1. Add a --sort col flag to sort rows by a column value
  2. Add --format json to output results as JSON instead of text
  3. Add a frequency count for string columns (most common values)
  4. Handle multi-line quoted fields correctly (CSV fields can contain newlines)

What you practised

Manual CSV parsing with quoted-field support, type detection via Array.every(), statistical reduce patterns (mean/median/stddev), dynamic row filtering with new Function, and piping data through a CLI with multiple flags.

Reference: Arrays · Regular Expressions · Destructuring & Spread

Try It Yourself