Guides / Input envelopes
Double-Encoded JSON to CSV: Decode One Envelope, Keep Field Text
Short answer: Parse the input once and inspect its top-level type. If the producer explicitly wrapped a records document in a JSON string, decode that one declared envelope before exporting the array. Do not repeatedly parse until something becomes an object, and do not parse individual text fields just because they resemble JSON.
An escaped quote does not by itself prove accidental double encoding. This guide distinguishes a complete document stored inside a string from legitimate string values inside records. It demonstrates a local Python exporter, not the behavior of this site's browser converter.
Find the boundary before changing anything
The same business records can arrive as an array or as a string containing the serialized array. The first parse of the latter succeeds, but its result is still text. Python's json.loads reference and decoding table distinguish arrays from strings; parsing successfully does not establish that the result matches your application's record schema.
Ask whether the API contract defines a string envelope. If it does not, retain the raw input and report the unexpected type. A user-supplied text value like [1,2] is not permission to reinterpret that field as an array.
Our original six-case boundary experiment
We ran these fixtures locally on 2026-10-24. The record schema is exactly two string fields, id and text. In both successful runs we parsed the CSV back and compared every field with the original records, including the JSON-looking text. Inputs are shown exactly as written, not as a debugger's quoted representation.
array
Envelope flag: off.
[{"id":"A1","text":"{\"state\":\"ready\"}"},{"id":"A2","text":"[1,2]"}]Observed CSV:
id,text
A1,"{""state"":""ready""}"
A2,"[1,2]"
wrapped-without-flag
Envelope flag: off.
"[{\"id\":\"A1\",\"text\":\"{\\\"state\\\":\\\"ready\\\"}\"},{\"id\":\"A2\",\"text\":\"[1,2]\"}]"Observed rejection before CSV destination creation:
Expected a records array after declared decodingwrapped-once
Envelope flag: on.
"[{\"id\":\"A1\",\"text\":\"{\\\"state\\\":\\\"ready\\\"}\"},{\"id\":\"A2\",\"text\":\"[1,2]\"}]"Observed CSV:
id,text
A1,"{""state"":""ready""}"
A2,"[1,2]"
triple-wrapped
Envelope flag: on.
"\"[{\\\"id\\\":\\\"A1\\\",\\\"text\\\":\\\"{\\\\\\\"state\\\\\\\":\\\\\\\"ready\\\\\\\"}\\\"},{\\\"id\\\":\\\"A2\\\",\\\"text\\\":\\\"[1,2]\\\"}]\""Observed rejection before CSV destination creation:
Expected a records array after declared decodingarray-with-flag
Envelope flag: on.
[{"id":"A1","text":"{\"state\":\"ready\"}"},{"id":"A2","text":"[1,2]"}]Observed rejection before CSV destination creation:
Envelope mode requires a top-level stringbad-inner
Envelope flag: on.
"[not JSON]"Observed rejection before CSV destination creation:
Expecting value: line 1 column 2 (char 1)The direct array and the explicitly decoded envelope produced identical CSV text. The text fields {"state":"ready"} and [1,2] remained strings. A third encoding layer was not silently unwrapped: after one authorized extra decode it still failed the array check.
Use an explicit mode, not a recursive repair loop
data = decode(raw)
if envelope:
if not isinstance(data, str):
raise ValueError("Envelope mode requires a top-level string")
data = decode(data)
if not isinstance(data, list):
raise ValueError("Expected a records array after declared decoding")The flag documents a producer boundary. It does not search for nested strings, remove backslashes globally or guess a number of layers. If the extra parse returns an object rather than an array, this specific recipe rejects it instead of inventing rows. An API object with a records member needs a separately agreed selection path.
Reproduce with a new output file
Download the standalone exporter, direct-array fixture, wrapped fixture, extra-layer rejection fixture and exact expected CSV.
python decode-json-envelope-export.py envelope-array.json direct-new.csv
python decode-json-envelope-export.py envelope-wrapped-once.json wrapped-new.csv --decode-envelopeUse fresh destination names. The exporter validates records before exclusive output creation, refuses an existing destination and leaves the source alone. Its 1,000,000-byte limit is a demo policy, not a measured converter limit. Use a trusted directory; exclusive creation is not a durable transaction or protection from every write failure. This example does not sanitize spreadsheet formulas, so review untrusted strings before spreadsheet import.
Full runnable code:
import argparse, csv, json
from pathlib import Path
def reject_constant(token):
raise ValueError("Non-JSON constant: " + token)
def unique_keys(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError("Duplicate key: " + key)
result[key] = value
return result
def decode(raw):
return json.loads(raw, parse_constant=reject_constant,
object_pairs_hook=unique_keys)
def export(source, target, envelope=False):
source, target = Path(source), Path(target)
if source.resolve() == target.resolve():
raise ValueError("Source and destination must differ")
if source.stat().st_size > 1_000_000:
raise ValueError("Demo byte cap exceeded")
data = decode(source.read_text(encoding="utf-8"))
if envelope:
if not isinstance(data, str):
raise ValueError("Envelope mode requires a top-level string")
data = decode(data) # one declared envelope, never a loop
if not isinstance(data, list):
raise ValueError("Expected a records array after declared decoding")
for row in data:
if not isinstance(row, dict) or set(row) != {"id", "text"}:
raise ValueError("Expected exact id/text objects")
if not all(isinstance(row[k], str) for k in ("id", "text")):
raise ValueError("id and text must be strings")
with target.open("x", encoding="utf-8", newline="") as f:
writer = csv.writer(f, lineterminator="\n")
writer.writerow(["id", "text"])
writer.writerows((row["id"], row["text"]) for row in data)
if __name__ == "__main__":
p = argparse.ArgumentParser()
p.add_argument("source")
p.add_argument("target")
p.add_argument("--decode-envelope", action="store_true")
args = p.parse_args()
try:
export(args.source, args.target, args.decode_envelope)
except (ValueError, OSError, RecursionError) as e:
p.exit(1, str(e) + "\n")
Keep the problem separate from escaping and nested rows
Ordinary JSON escape decoding happens during either authorized parse. This recipe preserves the resulting text field values, not the original source spelling of their escapes. If you need byte-for-byte source retention, use the source spelling and receipt workflow. If a real nested array needs its own rows, use the related-table export after choosing its actual array path.
The decoder rejects duplicate object keys and bare NaN/Infinity extensions, but those checks are not the point of the envelope flag. See the nonstandard-constant guide for that separate input policy. Neither recipe is a universal validator for arbitrary JSON types.
For handoff, record whether envelope mode was enabled and compare parsed CSV fields, not the number of backslashes visible in a preview. When the producer changes its envelope contract, fail at that boundary rather than automatically consuming more layers.