Guides / Column collection
JSON to CSV: Keep __proto__, constructor and prototype as Literal Columns
Short answer: A JSON document can contain own fields literally named __proto__, constructor and prototype, and JSON.parse keeps all three as ordinary own properties. If you collect column names into a plain {}, the own __proto__ key is silently lost: assignment goes through the inherited __proto__ setter, which ignores a non-object value. Collect columns with Object.create(null), a Set or a Map, then write each name as a literal CSV header.
A plain object is a convenient lookup table but not a neutral dictionary: it inherits __proto__ and other names from Object.prototype. This page reports a measured two-record Node export in which a naive column map drops one whole column, and a null-prototype map preserves it. It is a standalone local recipe, not a measurement of this site's browser converter.
Why a plain object drops the __proto__ column
The MDN reference for __proto__ states that the setter mutates an object's [[Prototype]], that the value "must be an object or null", and that "providing any other value will do nothing." A column collector usually writes a marker such as true, which is not an object. So cols["__proto__"] = true invokes the inherited setter, the setter ignores the value, and no own property named __proto__ is created. Later, Object.keys(cols) does not list it.
The same MDN page notes that null-prototype objects do not inherit the __proto__ accessor at all, so "any assignment to __proto__ would create a new property called __proto__ instead of setting the object's prototype." That is the fix used below. constructor and prototype are not special on a plain object: assigning those names creates ordinary own keys, so they survive even in the naive map. The measured failure is specific to __proto__.
Parsing is a separate step from collecting. MDN's JSON.parse page says the only case where JSON text differs from the same JavaScript expression is the "__proto__" key. In practice JSON.parse creates an own data property for it; an object literal would set the prototype instead. Read the parsed record, not a re-typed literal.
The original two-record fixture
The fixture is a JSON array with two records. Each record has an own __proto__, constructor and prototype field, plus an ordinary note. The second note contains a real newline, so a correct CSV writer must quote it.
[
{
"id": "A1",
"__proto__": "proto-value",
"constructor": "ctor-value",
"prototype": "prototype-value",
"note": "plain, text"
},
{
"id": "A2",
"__proto__": "second-proto",
"constructor": "c2",
"prototype": "p2",
"note": "line1\nline2"
}
]
Naive and null-prototype column collection, measured
Both collectors iterate Object.keys(record) over the parsed array, so they see the same four own keys per record. They differ only in the accumulator object. The export was run locally on 2026-10-29 with Node v22.22.2; the CSV was read back with Python 3.13.14's csv module, an independent parser.
| Collector | Header produced | __proto__ kept | Data rows |
|---|---|---|---|
const cols = {} | id, constructor, prototype, note | No | 2 |
Object.create(null) | id, __proto__, constructor, prototype, note | Yes | 2 |
The null-prototype export wrote 2 data rows and 5 columns. Every one of the 10 field comparisons against the parsed JSON values matched exactly, including the quoted comma and the quoted newline. The naive export also wrote 2 data rows, but only 4 columns: the entire __proto__ column is missing, and no value was shifted into a neighbouring column.
Copying a record has the same split. In the same run, Object.assign({}, record) did not retain an own __proto__ key, while the spread form { ...record } did. Both outcomes follow from the copy mechanism: Object.assign assigns through the setter, while spread defines data properties.
Export the names as literal CSV headers
Collect columns into a null-prototype object, then write each header verbatim. Use Object.keys on each record rather than for...in: if a prototype has been altered, for...in can also enumerate inherited names that are not fields of the record.
const cols = Object.create(null);
for (const rec of records) {
for (const key of Object.keys(rec)) cols[key] = true;
}
const header = Object.keys(cols);
// ["id", "__proto__", "constructor", "prototype", "note"]
const esc = (v) => {
const s = v === null || v === undefined ? "" : String(v);
return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
};
const rows = records.map((rec) =>
header.map((k) =>
esc(Object.prototype.hasOwnProperty.call(rec, k) ? rec[k] : "")
).join(",")
);
const csv = [header.map(esc).join(","), ...rows].join("\r\n") + "\r\n";
Checking Object.prototype.hasOwnProperty.call(rec, k) keeps a missing field distinct from a field whose value is the empty string or null. If your consumer needs that distinction preserved, pair this with the state-column approach in the missing/null/empty guide rather than emitting a bare empty cell for both cases.
What if __proto__ holds an object?
A string value is ignored by the setter, so the column is merely lost. An object value is different: the naive accumulator adopts that object as its own prototype. The table below records the measured checks; the global check asks whether ({}).polluted changed.
| Check | Naive {} | Object.create(null) |
|---|---|---|
Own __proto__ key created | No | Yes |
| Accumulator prototype changed | Yes, to the supplied object | No, stays null |
Global Object.prototype polluted | No | No |
| Stored value | Lost as a key | {"polluted":"local"} |
This is a column-integrity bug, not proof of global prototype pollution. In the measured run the accumulator inherited the supplied name, but Object.prototype itself was unchanged. The practical damage is still real: the real __proto__ column is missing, and the map now exposes a name the record never contained. A null-prototype accumulator avoids both effects.
Download the fixture, exporter and both CSV files
Run the exporter against the fixture; it writes both a safe CSV and a deliberately naive CSV so you can compare the headers yourself.
node proto-named-fields-export.js proto-named-fields-source.json
The exporter prints the record count, both headers and the dropped name as JSON. It does not rename, merge or delete any source field, and it does not read a network resource.
Scope and related guides
This page covers one narrow problem: preserving own JSON fields named __proto__, constructor or prototype when a local exporter collects CSV column names. It does not claim that every converter drops these fields, and it is not a security audit of any application. The measured result is specific to the shipped Node script and fixture; a different parser, framework or ORM may define its own handling.
For related column and key problems, see flattened column-name collisions (dotted keys versus nested paths), duplicate keys before CSV (own-key detection), explicit schema column alignment, and row and field verification after export.