CSV to JSON: Why a Column Called __proto__ Silently Disappears

Published 28 Sep 2026 · 8 min read

Paste a CSV with a column header of __proto__ into most hand-rolled CSV-to-JSON converters — including, until this post, this site's own CSV & Excel Toolkit — and that entire column vanishes from the output. No error, no warning, no truncated value. Every other column converts correctly. The row count matches. It just silently has one fewer field than the header row promised, and most people never notice, because nothing ever fails loudly enough to go looking. Reproduced directly in Node before touching any code, not assumed from an old writeup — and fixed live in the tool linked below.

Quick answer: __proto__ isn't a normal property name on a plain JavaScript object — it's an accessor inherited from Object.prototype that reads and writes the object's own prototype. When converter code loops over CSV headers and does obj[header] = value, a header named __proto__ hits that accessor instead of creating a property. Per MDN's own reference: "The value provided must be an object or null. Providing any other value will do nothing." A CSV cell is always a string, so the assignment is a silent no-op — the column just disappears.

Quick reference

Every claim below was checked directly in Node before writing a word of this post — the reference table's "own property?" column is Object.prototype.hasOwnProperty.call(obj, key), not an assumption.

Header nameobj[header] = valueOwn property afterward?Shows up in JSON.stringify?
emailCreates a normal propertyYesYes
constructorCreates a normal property (shadows the inherited one)YesYes
toStringCreates a normal property (shadows the inherited one)YesYes
__proto__Silent no-op — string isn't an object or nullNoNo — silently missing

1. Where this actually bites: a converter that loops over headers

This is the pattern behind almost every hand-written CSV or XML-to-JSON converter, including the one this site shipped until today:

// The pattern used by this site's own CSV & Excel Toolkit, before today's fix
const header = rows[0]; // e.g. ["name", "__proto__", "department"]
const result = rows.slice(1).map((r) => {
  const obj = {};
  header.forEach((key, i) => {
    obj[key] = r[i] ?? ''; // <-- the bug
  });
  return obj;
});

Feed it a completely ordinary-looking CSV:

name,__proto__,department
Ada Lovelace,polluted,Engineering
Grace Hopper,polluted,Engineering

And the JSON it produces is missing a field, silently:

// Verified directly in Node — node -e "..."
[
  { "name": "Ada Lovelace", "department": "Engineering" },
  { "name": "Grace Hopper", "department": "Engineering" }
]
// No __proto__ key anywhere. No error was thrown. No warning was logged.

Nothing about that output looks wrong at a glance — it's valid JSON, every other column is correct, and the row count still matches the input. The only way to notice is to already know to count the columns.

2. Why the assignment does nothing instead of throwing

On a plain object literal {}, __proto__ isn't stored as an own property — it's a getter/setter pair defined on Object.prototype itself (a legacy feature standardized for web compatibility, formally deprecated in favor of Object.getPrototypeOf() / Object.setPrototypeOf(), but still present in every JS engine). Reading obj.__proto__ calls the getter and returns the object's current prototype. Writing obj.__proto__ = value calls the setter, which per the spec — and confirmed on MDN's reference page — only actually does something if value is an object or null:

// Verified directly in Node
const obj = {};
obj['__proto__'] = 'polluted';         // a string — the setter's guard rejects it
console.log(Object.keys(obj));          // []
console.log(JSON.stringify(obj));       // {}
console.log(obj.__proto__ === Object.prototype); // true — unchanged

Compare that to a header named constructor, which looks just as "special" at a glance but behaves completely normally, because constructor is an ordinary data property on Object.prototype, not an accessor — assigning to it on obj just creates a normal own property that shadows the inherited one:

const obj2 = {};
obj2['constructor'] = 'polluted';
console.log(Object.keys(obj2));         // ["constructor"]
console.log(JSON.stringify(obj2));      // {"constructor":"polluted"}

That asymmetry is exactly what makes the __proto__ case so easy to miss in testing: a quick smoke test with a constructor or toString column would pass without incident, because only __proto__ itself is an accessor on Object.prototype.

Try it live

This link prefills this site's CSV & Excel Toolkit with the exact CSV from this post — a __proto__ column between two ordinary ones. Before this post, converting it silently dropped the middle column; the fix below now converts it correctly.

Open CSV & Excel Toolkit with the __proto__ column

3. This isn't limited to CSV — any converter that loops over untrusted keys shares it

The same pattern showed up independently in two more places in this codebase once we went looking: this site's String Lab CSV Inspector builds its JSON preview with the identical obj[key] = value loop, and the XML ↔ JSON Converter builds nested objects from XML tag names with obj[nodeName] = value — an XML tag literally named <__proto__> hits the exact same accessor. The XML case is actually worse in one respect: because obj[nodeName] is read back later to check "have we already seen this tag" (for building arrays out of repeated sibling tags), and reading obj['__proto__'] returns the current prototype object rather than undefined, that check can misfire even before the write happens. All three were fixed together, since they're the same root cause wearing three different names.

It's also not a new discovery in the abstract — it's the mechanism behind a well-known class of vulnerability. PortSwigger's Web Security Academy defines prototype pollution as "a JavaScript vulnerability that enables an attacker to add arbitrary properties to global object prototypes, which may then be inherited by user-defined objects" — a more dangerous, cross-object version of the same missing guard, and one that specifically requires the assigned value to be an object rather than a string. This post's bug is the quiet, data-loss half of that same coin: no cross-object mutation here, just a silently vanishing column, because every value coming out of a CSV cell is a string and the setter's own guard rejects it. But an unguarded obj[key] = value loop is the same shape of hole either way — it's worth fixing whether or not the current inputs happen to be strings.

The fix: guard the key, not the value

The minimal, correct fix doesn't touch how values are read from the CSV — it routes the three unsafe key names through Object.defineProperty, which always creates a real own property regardless of name, instead of through bracket assignment:

const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);

function setSafe(obj, key, value) {
  if (UNSAFE_KEYS.has(key)) {
    Object.defineProperty(obj, key, {
      value, enumerable: true, writable: true, configurable: true
    });
  } else {
    obj[key] = value;
  }
}

Verified against the same case that silently dropped the column above:

const obj = {};
setSafe(obj, '__proto__', 'polluted');
console.log(Object.keys(obj));           // ["__proto__"]  — now a real own property
console.log(JSON.stringify(obj));        // {"__proto__":"polluted"}
console.log(obj.__proto__ === Object.prototype); // false — no longer the accessor

This is exactly the fix now shipped across CSV & Excel Toolkit, String Lab's CSV Inspector, and XML ↔ JSON Converter. A simpler alternative worth knowing for a fresh implementation: build the result object with Object.create(null) instead of {} — an object with no prototype at all has no inherited __proto__ accessor to trip over in the first place, so plain bracket assignment becomes safe again. That trades away hasOwnProperty and friends (also inherited from Object.prototype), which is a reasonable trade for a pure data container but not a drop-in fix for existing code that expects a normal object.

Debugging checklist

  1. If your converter builds objects with a loop like header.forEach((key, i) => { obj[key] = value; }) where key comes from untrusted input — a CSV header, an XML tag name, a URL query parameter, a form field name — assume a key of __proto__ silently drops its value until you've checked.
  2. Reproduce it yourself in a console: const o = {}; o['__proto__'] = 'x'; console.log(JSON.stringify(o)); — if that prints {}, your object-building code has this gap.
  3. Fix it by routing __proto__, constructor, and prototype through Object.defineProperty, or build with Object.create(null) from the start.
  4. If the values being assigned could ever be objects rather than strings — a nested JSON structure, a parsed sub-object, anything from JSON.parse assigned manually rather than via JSON.parse itself — treat the same gap as a real prototype-pollution risk, not just a data-loss one, since an object value does pass the __proto__ setter's guard.

Related to this: the same "the accessor intercepts the write before it becomes a normal property" shape is also why JSON.parse() itself has to special-case __proto__ internally (it uses spec-level CreateDataPropertyOrThrow, not a plain [[Set]], which is why JSON.parse('{"__proto__":"x"}') round-trips fine even though manual bracket assignment doesn't) — see this site's guide to JSON parsing errors for more on how JSON.parse differs from hand-rolled object construction.

🗂️ Convert CSV to JSON without losing a column

CSV & Excel Toolkit now routes __proto__, constructor, and prototype headers through a safe property assignment instead of a bare obj[key] = value — no more silently vanishing columns. Everything runs locally in your browser; nothing you paste is uploaded.

Open CSV & Excel Toolkit →

← Back to Blog · See also: Unexpected Token in JSON — Every Cause and Fix →