CSV Validator (Browser + Server)

February 10, 2026 NEW

Here is a lightweight CSV validator you can drop into any website. It lets someone upload a CSV and instantly tells them what’s wrong: missing headers, mismatched column counts, blank required fields, and simple format checks (email/URL/number).

Why it’s useful: CSV imports fail for the same boring reasons every time. This makes the errors obvious before someone hits “Import” and blows up your pipeline.

What it does:

  • Validates required headers (order optional).
  • Flags row column count mismatches (broken commas/quotes).
  • Checks required fields are not empty.
  • Optional format rules: email, URL, integer, number.
  • Shows a readable report: line number + what failed.

Install:

  1. Create: /tools/csv-validator/
  2. Save the file below as: index.php
  3. Edit the config section (required headers + rules).
  4. Visit: /tools/csv-validator/
<?php
declare(strict_types=1);

/**
 * Tiny CSV Validator — One File (No DB)
 * File: /tools/csv-validator/index.php
 *
 * - Client-side validation for instant feedback
 * - Optional server-side validation endpoint (POST file) for parity
 */

header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: strict-origin-when-cross-origin');

$CFG = [
  'tool_name' => 'Tiny CSV Validator',
  'desc'      => 'Validate headers, column counts, required fields, and basic formats before importing.',
  'canonical' => 'https://your-site.com/tools/csv-validator/', // change

  // REQUIRED HEADERS (case-insensitive)
  // Example CSV must include these columns (in any order).
  'required_headers' => ['name','email','url'],

  // REQUIRED FIELDS (subset of headers)
  'required_fields'  => ['name','email'],

  // FORMAT RULES per column
  // Supported: email, url, int, number
  'rules' => [
    'email' => 'email',
    'url'   => 'url',
  ],

  // Safety
  'max_bytes' => 2_000_000, // 2MB
  'max_rows'  => 10_000,
];

function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); }

function is_email(string $s): bool {
  return filter_var($s, FILTER_VALIDATE_EMAIL) !== false;
}
function is_url(string $s): bool {
  if ($s === '') return false;
  // allow schemeless? keep strict-ish
  return filter_var($s, FILTER_VALIDATE_URL) !== false;
}
function is_int_str(string $s): bool {
  return (bool)preg_match('/^-?\d+$/', $s);
}
function is_number_str(string $s): bool {
  return (bool)preg_match('/^-?(?:\d+|\d*\.\d+)$/', $s);
}

function normalize_header(string $s): string {
  $s = trim($s);
  $s = strtolower($s);
  // normalize spaces/underscores
  $s = preg_replace('/\s+/', ' ', $s) ?? $s;
  return $s;
}

function server_validate_csv(string $csv, array $CFG): array {
  $errors = [];
  $warnings = [];

  // Basic BOM strip
  $csv = preg_replace('/^\xEF\xBB\xBF/', '', $csv) ?? $csv;

  // Parse with fgetcsv semantics using memory stream
  $fp = fopen('php://memory', 'r+');
  fwrite($fp, $csv);
  rewind($fp);

  $headers = fgetcsv($fp);
  if (!$headers || !is_array($headers)) {
    return ['ok'=>false,'errors'=>[['line'=>1,'msg'=>'Missing header row.']],'warnings'=>[]];
  }

  $norm = [];
  foreach ($headers as $i => $col) {
    $norm[$i] = normalize_header((string)$col);
  }

  // map header => index
  $map = [];
  foreach ($norm as $i => $col) {
    if ($col !== '' && !isset($map[$col])) $map[$col] = $i;
  }

  // required headers
  foreach ($CFG['required_headers'] as $req) {
    $reqN = normalize_header((string)$req);
    if (!isset($map[$reqN])) $errors[] = ['line'=>1,'msg'=>"Missing required header: {$req}"];
  }
  if ($errors) return ['ok'=>false,'errors'=>$errors,'warnings'=>$warnings];

  $colCount = count($headers);
  $line = 1;
  $rows = 0;

  while (($row = fgetcsv($fp)) !== false) {
    $line++;
    if ($row === [null] || $row === null) continue;

    $rows++;
    if ($rows > (int)$CFG['max_rows']) {
      $warnings[] = ['line'=>$line,'msg'=>'Stopped early: max rows reached.'];
      break;
    }

    if (count($row) !== $colCount) {
      $errors[] = ['line'=>$line,'msg'=>"Column count mismatch (expected {$colCount}, got ".count($row).")."];
      continue;
    }

    // required fields
    foreach ($CFG['required_fields'] as $rf) {
      $rfN = normalize_header((string)$rf);
      $idx = $map[$rfN] ?? null;
      if ($idx === null) continue;
      $val = trim((string)($row[$idx] ?? ''));
      if ($val === '') $errors[] = ['line'=>$line,'msg'=>"Required field empty: {$rf}"];
    }

    // rules
    foreach ($CFG['rules'] as $col => $rule) {
      $cN = normalize_header((string)$col);
      $idx = $map[$cN] ?? null;
      if ($idx === null) continue;
      $val = trim((string)($row[$idx] ?? ''));
      if ($val === '') continue; // allow blank unless required

      $ok = true;
      if ($rule === 'email')  $ok = is_email($val);
      if ($rule === 'url')    $ok = is_url($val);
      if ($rule === 'int')    $ok = is_int_str($val);
      if ($rule === 'number') $ok = is_number_str($val);

      if (!$ok) $errors[] = ['line'=>$line,'msg'=>"Invalid {$rule} in column '{$col}': {$val}"];
    }
  }

  fclose($fp);

  return ['ok'=>count($errors)===0,'errors'=>$errors,'warnings'=>$warnings];
}

// -------- optional server endpoint --------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv'])) {
  if ((int)($_FILES['csv']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
    http_response_code(400);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode(['ok'=>false,'errors'=>[['line'=>0,'msg'=>'Upload failed.']]], JSON_UNESCAPED_SLASHES);
    exit;
  }

  $tmp = (string)($_FILES['csv']['tmp_name'] ?? '');
  $sz  = (int)($_FILES['csv']['size'] ?? 0);
  if ($tmp === '' || $sz <= 0) {
    http_response_code(400);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode(['ok'=>false,'errors'=>[['line'=>0,'msg'=>'Empty upload.']]], JSON_UNESCAPED_SLASHES);
    exit;
  }
  if ($sz > (int)$CFG['max_bytes']) {
    http_response_code(413);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode(['ok'=>false,'errors'=>[['line'=>0,'msg'=>'File too large.']]], JSON_UNESCAPED_SLASHES);
    exit;
  }

  $csv = (string)@file_get_contents($tmp);
  $r = server_validate_csv($csv, $CFG);

  header('Content-Type: application/json; charset=utf-8');
  echo json_encode($r, JSON_UNESCAPED_SLASHES);
  exit;
}

// -------- page --------
$title = $CFG['tool_name'];
$desc  = $CFG['desc'];
?><!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title><?php echo h($title); ?></title>
  <meta name="description" content="<?php echo h($desc); ?>" />
  <link rel="canonical" href="<?php echo h((string)$CFG['canonical']); ?>" />
  <meta name="robots" content="index,follow,max-snippet:-1,max-image-preview:large,max-video-preview:-1" />

  <style>
    :root{
      --bg:#0b0f16; --panel:#111a26; --panel2:#0c1420;
      --text:#e8eef8; --muted:rgba(232,238,248,.72);
      --line:rgba(255,255,255,.10); --accent:#2a8cff;
      --shadow:0 18px 55px rgba(0,0,0,.45); --r:16px;
      --ok:#7CFF9A; --warn:#FFD37C; --bad:#FF7C7C;
    }
    *{box-sizing:border-box}
    body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif}
    .wrap{max-width:980px;margin:0 auto;padding:18px}
    .card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:14px;margin:12px 0;box-shadow:var(--shadow)}
    .muted{color:var(--muted)}
    .top{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;align-items:center}
    .pill{display:inline-block;padding:4px 10px;border-radius:999px;background:rgba(42,140,255,.14);border:1px solid rgba(42,140,255,.25);font-weight:900;font-size:12px}
    .btn{display:inline-flex;align-items:center;gap:8px;border:0;border-radius:12px;padding:10px 12px;background:var(--accent);color:#06101a;font-weight:900;cursor:pointer}
    input[type=file]{width:100%;padding:10px;border-radius:12px;border:1px solid var(--line);background:var(--panel2);color:var(--text)}
    pre{margin:0;padding:12px;border-radius:14px;border:1px solid var(--line);background:var(--panel2);overflow:auto;white-space:pre-wrap}
    code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
    .row{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:12px}
    .c7{grid-column:span 7}
    .c5{grid-column:span 5}
    @media(max-width:920px){.c7,.c5{grid-column:span 12}}
    .ok{color:var(--ok)} .warn{color:var(--warn)} .bad{color:var(--bad)}
    table{width:100%;border-collapse:collapse}
    th,td{padding:10px;border-bottom:1px solid rgba(255,255,255,.08);vertical-align:top}
    th{font-size:12px;opacity:.75;text-align:left}
  </style>
</head>
<body>
<div class="wrap">

  <div class="card">
    <div class="top">
      <div>
        <div class="pill">CSV Tool</div>
        <h1 style="margin:10px 0 6px;font-size:22px"><?php echo h($title); ?></h1>
        <div class="muted"><?php echo h($desc); ?></div>
      </div>
    </div>
  </div>

  <div class="row">
    <div class="card c7">
      <b>Upload a CSV</b>
      <div class="muted" style="margin-top:6px">
        Required headers: <code><?php echo h(implode(', ', $CFG['required_headers'])); ?></code>
      </div>

      <div style="margin-top:10px;display:grid;gap:10px">
        <input id="csvFile" type="file" accept=".csv,text/csv" />
        <button class="btn" type="button" id="validateBtn">Validate</button>
        <div class="muted" style="font-size:13px;line-height:1.45">
          Validates in the browser first (fast). Optionally also checks via PHP (same rules).
        </div>
      </div>
    </div>

    <div class="card c5">
      <b>Sample CSV</b>
      <div class="muted" style="margin-top:6px">Copy this into a file called <code>test.csv</code> to see it pass.</div>
      <pre><code>name,email,url
Jane Doe,jane@example.com,https://example.com
John Smith,john@example.com,https://example.org</code></pre>
    </div>
  </div>

  <div class="card" id="reportCard" style="display:none">
    <div class="top">
      <b>Report</b>
      <div id="summary" class="muted"></div>
    </div>
    <div style="margin-top:10px;overflow:auto">
      <table>
        <thead><tr><th>Type</th><th>Line</th><th>Message</th></tr></thead>
        <tbody id="rows"></tbody>
      </table>
    </div>
  </div>

</div>

<script>
(function(){
  var REQUIRED_HEADERS = <?php echo json_encode(array_values($CFG['required_headers']), JSON_UNESCAPED_SLASHES); ?>;
  var REQUIRED_FIELDS  = <?php echo json_encode(array_values($CFG['required_fields']),  JSON_UNESCAPED_SLASHES); ?>;
  var RULES            = <?php echo json_encode($CFG['rules'], JSON_UNESCAPED_SLASHES); ?>;

  function normHeader(s){
    return String(s||"").trim().toLowerCase().replace(/\s+/g," ");
  }
  function isEmail(s){
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(s||"").trim());
  }
  function isURL(s){
    try { new URL(String(s||"").trim()); return true; } catch(e){ return false; }
  }
  function isInt(s){
    return /^-?\d+$/.test(String(s||"").trim());
  }
  function isNumber(s){
    return /^-?(?:\d+|\d*\.\d+)$/.test(String(s||"").trim());
  }

  // Minimal CSV parser that respects quotes.
  // Good enough for validation + line counts.
  function parseCSV(text){
    text = String(text||"");
    // strip UTF-8 BOM
    if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
    text = text.replace(/^\uFEFF/, "");

    var rows = [];
    var row = [];
    var cur = "";
    var inQ = false;

    for (var i=0;i<text.length;i++){
      var ch = text[i];

      if (inQ){
        if (ch === '"'){
          if (text[i+1] === '"'){ cur += '"'; i++; }
          else inQ = false;
        } else {
          cur += ch;
        }
      } else {
        if (ch === '"'){ inQ = true; }
        else if (ch === ','){ row.push(cur); cur=""; }
        else if (ch === '\n'){
          row.push(cur); cur="";
          // trim CR
          if (row.length===1 && row[0]==="" && rows.length===0) { row=[]; continue; }
          rows.push(row); row=[];
        } else if (ch === '\r'){
          // ignore
        } else {
          cur += ch;
        }
      }
    }
    row.push(cur);
    if (row.length>1 || row[0] !== "") rows.push(row);
    return rows;
  }

  function validateRows(rows){
    var errors = [];
    var warnings = [];
    if (!rows || !rows.length) {
      errors.push({line:1,msg:"Empty file."});
      return {ok:false, errors:errors, warnings:warnings};
    }

    var header = rows[0];
    if (!header || !header.length) {
      errors.push({line:1,msg:"Missing header row."});
      return {ok:false, errors:errors, warnings:warnings};
    }

    var norm = header.map(normHeader);
    var map = {};
    for (var i=0;i<norm.length;i++){
      if (norm[i] && map[norm[i]] == null) map[norm[i]] = i;
    }

    REQUIRED_HEADERS.forEach(function(h){
      var k = normHeader(h);
      if (map[k] == null) errors.push({line:1,msg:"Missing required header: " + h});
    });

    if (errors.length) return {ok:false, errors:errors, warnings:warnings};

    var colCount = header.length;

    for (var r=1;r<rows.length;r++){
      var line = r+1;
      var row = rows[r];

      if (row.length !== colCount) {
        errors.push({line:line,msg:"Column count mismatch (expected " + colCount + ", got " + row.length + ")."});
        continue;
      }

      REQUIRED_FIELDS.forEach(function(f){
        var idx = map[normHeader(f)];
        if (idx == null) return;
        var v = String(row[idx]||"").trim();
        if (!v) errors.push({line:line,msg:"Required field empty: " + f});
      });

      Object.keys(RULES).forEach(function(col){
        var idx = map[normHeader(col)];
        if (idx == null) return;
        var v = String(row[idx]||"").trim();
        if (!v) return;
        var rule = RULES[col];

        var ok = true;
        if (rule === "email") ok = isEmail(v);
        if (rule === "url") ok = isURL(v);
        if (rule === "int") ok = isInt(v);
        if (rule === "number") ok = isNumber(v);

        if (!ok) errors.push({line:line,msg:"Invalid " + rule + " in column '" + col + "': " + v});
      });
    }

    return {ok: errors.length===0, errors: errors, warnings: warnings};
  }

  function renderReport(r){
    var card = document.getElementById("reportCard");
    var rowsEl = document.getElementById("rows");
    var sum = document.getElementById("summary");

    card.style.display = "block";
    rowsEl.innerHTML = "";

    var ok = r.ok;
    var e = r.errors || [];
    var w = r.warnings || [];

    sum.innerHTML = ok
      ? '<span class="ok"><b>Looks good.</b></span> No errors.'
      : '<span class="bad"><b>Problems found.</b></span> Errors: ' + e.length;

    function add(type, line, msg){
      var tr = document.createElement("tr");
      tr.innerHTML =
        '<td><b class="'+(type==="ERROR"?"bad":"warn")+'">'+type+'</b></td>' +
        '<td>'+line+'</td>' +
        '<td>'+msg.replace(/[<>&]/g, function(c){ return ({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]); })+'</td>';
      rowsEl.appendChild(tr);
    }

    e.forEach(function(x){ add("ERROR", x.line, x.msg); });
    w.forEach(function(x){ add("WARN", x.line, x.msg); });

    if (ok && !w.length) {
      add("WARN", "—", "Tip: hook this into your import flow and block uploads until it passes.");
    }
  }

  var fileEl = document.getElementById("csvFile");
  var btn = document.getElementById("validateBtn");

  btn.addEventListener("click", function(){
    var f = fileEl.files && fileEl.files[0];
    if (!f) return alert("Choose a CSV file first.");

    var reader = new FileReader();
    reader.onload = function(){
      var text = String(reader.result || "");
      var rows = parseCSV(text);
      var r = validateRows(rows);
      renderReport(r);

      // Optional: also validate via server for parity
      // (Uncomment if you want both checks)
      /*
      var fd = new FormData();
      fd.append("csv", f);
      fetch(location.href, {method:"POST", body:fd})
        .then(function(res){ return res.json(); })
        .then(function(sr){
          // merge results (show server errors too)
          if (sr && sr.errors && sr.errors.length) {
            renderReport({ok:false, errors: sr.errors, warnings: sr.warnings||[]});
          }
        })
        .catch(function(){});
      */
    };
    reader.readAsText(f);
  });
})();
</script>
</body>
</html>

Comments (0)

No comments yet — be the first.

← Back to all scripts