A CSV parser will accept the header customer_id,name and a row that starts with a�(,Ada. The column count is right. The identifier was already damaged before the parser saw it.
That is the trap with CSV imports. Damage can happen when bytes become a string, and a parser cannot detect it afterward. This guide builds a small browser pipeline in a fixed order: size check, strict UTF-8 decode, CSV parse, header and column checks, domain rules, then a preview before anything replaces existing data. Every failure leaves the previous data untouched.
All code below is unexecuted, and no output was recorded. The expected results come from the DEV post Reject malformed UTF-8 before it reaches your CSV parser and from reading the code. Run everything yourself before relying on it.
Step 1: Reproduce the failure first
Start with the bytes [0x61, 0xC3, 0x28]. In UTF-8, 0xC3 begins a multi-byte sequence, but 0x28 (the ( character) cannot complete it.
// Unexecuted example. Needs a modern browser console or Node.js.
const bytes = new Uint8Array([0x61, 0xc3, 0x28]);
console.log(new TextDecoder('utf-8').decode(bytes));
// Expected, per the DEV post: a�(
try {
new TextDecoder('utf-8', { fatal: true }).decode(bytes);
} catch (error) {
console.log(error instanceof TypeError);
// Expected, per the DEV post: true
}
The default decoder substitutes U+FFFD, the replacement character, and keeps going. With fatal: true, decoding throws a TypeError.
The default suits displaying imperfect text. An import that turns values into lookup keys needs the opposite.
Step 2: Cap the size, then decode strictly
Decode the original bytes before any parser sees them. A parser only receives a string, and by then the replacement character looks like any other character.
// Unexecuted example. No dependencies.
class ImportError extends Error {
constructor(code, message) {
super(message);
this.code = code;
}
}
async function readUtf8(file) {
const maxBytes = 2 * 1024 * 1024;
if (file.size > maxBytes) {
throw new ImportError('size', 'Choose a file no larger than 2 MiB.');
}
const buffer = await file.arrayBuffer();
try {
return new TextDecoder('utf-8', { fatal: true }).decode(buffer);
} catch (error) {
if (error instanceof TypeError) {
throw new ImportError('encoding', 'This file is not valid UTF-8.');
}
throw error;
}
}
The size check runs before file.arrayBuffer() reads anything into memory. The function then reads the whole file at once, so it suits bounded uploads of a few MiB, not streaming imports of large files. The 2 MiB limit is an example value. Pick your own.
Wrapping the TypeError in an ImportError lets the interface tell an encoding failure apart from other problems.
Step 3: Parse and validate as separate stages
Decoding says nothing about CSV structure. Keep parsing and validation as separate hooks so you can test each one alone.
Decoding needs no dependencies. For parsing, plug in a CSV library, pin its exact version in your lockfile, and verify its quoting behavior against your own test files. This guide recommends no specific parser. The stand-in below is for demonstrations only, because splitting on commas breaks on quoted fields and embedded newlines.
// Unexecuted example. Demo-only parser: do not ship this.
function parseCsv(text) {
return text.trim().split(/\r?\n/).map((line) => line.split(','));
}
const REQUIRED_HEADERS = ['customer_id', 'name'];
function validateTable(rows) {
if (rows.length < 2) return ['The file needs a header and at least one data row.']; This pairs well with [go http server graceful shutdown: fix the common bugs](/go-http-server-graceful-shutdown-fix-the-common-bugs).
const errors = [];
const [header, ...body] = rows;
for (const name of REQUIRED_HEADERS) {
if (!header.includes(name)) errors.push('Missing required header: ' + name);
}
const idIndex = header.indexOf('customer_id');
body.forEach((row, i) => {
const rowNumber = i + 2;
if (row.length !== header.length) {
errors.push('Row ' + rowNumber + ': expected ' + header.length + ' columns, found ' + row.length + '.');
return;
}
// Illustrative domain rule: IDs are digits only.
if (idIndex !== -1 && !/^[0-9]+$/.test(row[idIndex])) {
errors.push('Row ' + rowNumber + ': customer_id must be digits only.');
}
});
return errors;
}
async function prepareImport(file, parse) {
const text = await readUtf8(file);
let rows;
try {
rows = parse(text);
} catch (error) {
throw new ImportError('syntax', 'The CSV could not be parsed: ' + error.message);
}
const errors = validateTable(rows);
if (errors.length > 0) {
throw new ImportError('validation', errors.slice(0, 5).join(' '));
}
return rows;
}
The digits-only rule would reject the damaged identifier from the opening example, but only because of this particular schema. Domain rules do not replace strict decoding. A damaged free-text name would pass straight through.
Step 4: Build a failure path that protects existing data
prepareImport returns rows or throws. Nothing in it touches the data you already imported. The UI keeps that separation by holding the new rows as pending until the user confirms.
// Unexecuted example. Assumes #file, #error, and #confirm elements exist,
// and that you supply renderPreview().
let importedRows = [];
let pendingRows = null;
function messageFor(error) {
if (error.code === 'encoding') {
return 'This file is not valid UTF-8, so nothing was imported. Re-export it from the source application as UTF-8 CSV and upload it again. Your previous data is unchanged.';
}
if (error.code) return error.message + ' Your previous data is unchanged.';
return 'Unexpected error while reading the file. Your previous data is unchanged.';
} See [a closer look at how to compare software development quotes like for like](/how-to-compare-software-development-quotes-like-for-like) for additional background.
document.querySelector('#file').addEventListener('change', async (event) => {
const errorBox = document.querySelector('#error');
const confirm = document.querySelector('#confirm');
pendingRows = null;
confirm.disabled = true;
errorBox.textContent = '';
const file = event.target.files[0];
if (!file) return;
try {
pendingRows = await prepareImport(file, parseCsv);
renderPreview(pendingRows.slice(0, 6));
confirm.disabled = false;
} catch (error) {
errorBox.textContent = messageFor(error);
}
});
The encoding message does three things. It says what failed, it says what to do (re-export as UTF-8), and it confirms that existing data is safe.
The DEV post also lets the user explicitly choose another supported encoding. If you add that option, make it a visible user decision, not a code path that runs on its own.
Step 5: Replace data only on confirmation
// Unexecuted example.
document.querySelector('#confirm').addEventListener('click', () => {
if (!pendingRows) return;
importedRows = pendingRows;
pendingRows = null;
document.querySelector('#confirm').disabled = true;
});
The preview puts the first few parsed rows in front of a person before anything is replaced. It is the last check, and the only one that relies on human judgment.
What fatal: true does not prove
Strict decoding proves one thing: the bytes are valid UTF-8. It does not prove the values are correct. It does not prove the source application used UTF-8. And it says nothing about whether the file is well-formed CSV.
Two cases show the limits:
- ASCII-only files pass. Such a file cannot show whether the author intended UTF-8 or another ASCII-compatible encoding.
- A literal U+FFFD already stored in valid UTF-8 passes. The bytes are legitimate, so the decoder has nothing to reject.
This is why the later stages exist. The decoder guards the byte boundary, and everything after it needs its own check.
Do not retry other encodings automatically
It is tempting to catch the failure and try another encoding until one returns a string. Don't. Successful decoding under another encoding does not establish what the file's author intended.
A silent fallback turns a loud, fixable error into a quiet one. If you support other encodings, make the user choose, and consult the WHATWG Encoding Standard for the encodings you offer.
Also read: related topic: filesystem mcp server in claude desktop: safe setup guide
Import pipeline checklist
- Enforce a size limit before reading the file into memory.
- Decode the raw bytes with
new TextDecoder('utf-8', { fatal: true }). - Check CSV syntax with a pinned, verified parser.
- Confirm required headers exist.
- Confirm every row has the expected column count.
- Apply domain rules to individual values.
- Show a preview of the parsed rows.
- Replace existing data only after the user confirms.
Verify it works
Test the failure case at two levels. First, run the Step 1 snippet. Default decoding should give a�(, and strict decoding should throw a TypeError.
Then, in a browser console, wrap the same bytes in a file and call your reader:
// Unexecuted example.
const bad = new File([new Uint8Array([0x61, 0xc3, 0x28])], 'bad.csv');
readUtf8(bad).catch((e) => console.log(e.code));
// Expected: encoding
Finally, import a good file through the UI, then upload the bad one. The error should appear, the confirm button should stay disabled, and importedRows should still hold the earlier data.
Limits
This pipeline reads the whole file at once, so files beyond a few MiB need a different design. The demo parser is a placeholder to replace with a pinned, verified library, and the digits-only rule is an example to replace with your own domain rules.
References
- MDN: TextDecoder options
- MDN: decode errors
- WHATWG Encoding Standard
- Reject malformed UTF-8 before it reaches your CSV parser (DEV)



