Guides / leading plus sign tokens

JSON to CSV: Distinguish Leading Plus +42, Unsigned 42, and String "+42"

When the same JSON field is the number 42 in one record, the string "+42" in the next, and the string "42" in a third, a naive CSV exporter writes identical text cells for numbers and their string lookalikes. Per RFC 8259 section 6, JSON numbers cannot have a leading plus sign, so a value like +42 can only appear in JSON as a string. An explicit sign-token column policy records the kind, sign marker, value type, source token and JSON serialization so a CSV reader can filter leading-plus strings from unsigned numbers without re-parsing the original JSON.

Short answer

A field like score that is the JSON number 42 in R01, the JSON string "+42" in R02, the JSON string "42" in R03, and the JSON number -17 in R04 has different JSON types that a naive CSV exporter collapses into bare text. Do not write all of them into one score column. Instead, expand the field into five columns: score_kind ("number_unsigned", "string_leading_plus", "string_no_sign", "number_negative"), score_sign_marker ("none", "leading_plus", "leading_minus"), score_value_type ("number" or "string"), score_token (the exact source spelling, e.g. +42), and score_json (the JSON serialization of the parsed value, e.g. "+42" for strings and 42 for numbers). This lets any CSV reader distinguish a JSON number from a string that looks like a number, identify values that carry a leading plus sign (which can only be strings), and reconstruct the original JSON value exactly.

This guide demonstrates a measured ten-record fixture exported locally on 2026-11-07 with Python 3.13.14. The source has four JSON numbers (42, -17, 0, 999) and six JSON strings: three with a leading plus ("+42", "+0", "+999"), one with a leading minus ("-17"), and two without a sign that look numeric ("42", "0"). The explicit exporter writes ten CSV rows with six columns; every field was read back and matched exactly (60/60 checks). A naive exporter that writes a single score column produces three collisions: R01's number 42 and R03's string "42" both become cell 42; R04's number -17 and R05's string "-17" both become cell -17; R06's number 0 and R08's string "0" both become cell 0.

The ten-record fixture

The source array contains ten records. The score field alternates between JSON numbers and JSON strings, including strings with leading plus, leading minus, and no sign that look numeric:

[
  {"id": "R01", "score": 42},
  {"id": "R02", "score": "+42"},
  {"id": "R03", "score": "42"},
  {"id": "R04", "score": -17},
  {"id": "R05", "score": "-17"},
  {"id": "R06", "score": 0},
  {"id": "R07", "score": "+0"},
  {"id": "R08", "score": "0"},
  {"id": "R09", "score": 999},
  {"id": "R10", "score": "+999"}
]

Download the original JSON sample. Per RFC 8259 section 6, the JSON number grammar defines an optional minus sign (-) followed by an integer part, but does not allow a leading plus sign (+). A number like 42 is valid JSON; +42 as a number is not. This means that any JSON value beginning with + must be a string, not a number. Python's json.loads (documented at docs.python.org) parses 42 as Python int and "+42" as Python str, but a naive CSV exporter writes both as bare text cells, losing the type distinction.

What a naive exporter gets wrong

A naive exporter writes a single score column using str(val) for numbers and val for strings. For the JSON number 42 it writes 42. For the JSON string "42" it also writes 42 — the cell content is identical. This creates three measurable collisions:

CollisionWhat happensMeasured result
Number vs string lookalikeR01 number 42 and R03 string "42" both produce cell 42Reader cannot tell which row had a JSON number and which had a string
Negative number vs stringR04 number -17 and R05 string "-17" both produce cell -17Type identity lost; filtering by value matches both
Zero vs string zeroR06 number 0 and R08 string "0" both produce cell 0Falsy number and falsy-looking string are indistinguishable

The naive CSV output shows these collisions concretely. R01 (42 as number) and R03 ("42" as string) both appear as 42. R04 (-17 as number) and R05 ("-17" as string) both appear as -17. R06 (0 as number) and R08 ("0" as string) both appear as 0:

id,score
R01,42
R02,+42
R03,42
R04,-17
R05,-17
R06,0
R07,+0
R08,0
R09,999
R10,+999

Notice that R02 ("+42"), R07 ("+0") and R10 ("+999") are the only rows that visibly differ — the leading + sign reveals them as strings, because JSON numbers cannot contain +. But R03 ("42"), R05 ("-17") and R08 ("0") have no visible marker that distinguishes them from their number counterparts. A downstream system that receives this CSV cannot tell whether cell 42 in R03 was originally a JSON number or a JSON string. This distinction matters in data pipelines where string values represent user input (typed text) and number values represent computed or stored quantities — mixing them silently can corrupt aggregations, type checks, or downstream schema validation.

The explicit sign-token column policy

Instead of one bare column, expand the field into five columns that together carry the full sign and type information:

SCHEMA = ["id", "score_kind", "score_sign_marker", "score_value_type", "score_token", "score_json"]

The score_kind column classifies each value by combining its JSON type with its sign: "number_unsigned" for JSON numbers without a sign (42, 0, 999), "number_negative" for JSON numbers with a leading minus (-17), "string_leading_plus" for JSON strings beginning with + ("+42", "+0", "+999"), "string_leading_minus" for JSON strings beginning with - ("-17"), and "string_no_sign" for JSON strings without a sign prefix that may look numeric ("42", "0").

The score_sign_marker column records whether the source token has a sign prefix: "none" (no leading + or -), "leading_plus" (the value starts with +), or "leading_minus" (the value starts with -). The score_value_type column records the JSON type: "number" or "string". The score_token column stores the exact source spelling without JSON quotes (42, +42, -17). The score_json column stores json.dumps(val), which is the canonical JSON serialization (42 for numbers, "+42" for strings — the quotes are visible in the JSON representation). Python's csv module documentation describes the DictWriter used to write the output.

The score_value_type column is the key differentiator between R01 and R03. Both have score_token=42, but R01 has score_value_type=number while R03 has score_value_type=string. A downstream reader can filter score_value_type=number to get only JSON numbers, or filter score_sign_marker=leading_plus to get only values that carry a + prefix — which are always strings per RFC 8259.

Measured export results

The exporter was run locally on 2026-11-07 with Python 3.13.14. Source SHA-256: 4e19a4f2…60b0 (319 bytes). Output SHA-256: ffd6a182…50bd (553 bytes). The CSV was read back with Python's csv.DictReader and every field compared against the parsed source.

RowIDSource valuePython typescore_kindscore_sign_markerscore_value_typescore_tokenscore_jsonRead-back
0R0142intnumber_unsignednonenumber4242✓ all 6 fields
1R02"+42"strstring_leading_plusleading_plusstring+42"+42"✓ all 6 fields
2R03"42"strstring_no_signnonestring42"42"✓ all 6 fields
3R04-17intnumber_negativeleading_minusnumber-17-17✓ all 6 fields
4R05"-17"strstring_leading_minusleading_minusstring-17"-17"✓ all 6 fields
5R060intnumber_unsignednonenumber00✓ all 6 fields
6R07"+0"strstring_leading_plusleading_plusstring+0"+0"✓ all 6 fields
7R08"0"strstring_no_signnonestring0"0"✓ all 6 fields
8R09999intnumber_unsignednonenumber999999✓ all 6 fields
9R10"+999"strstring_leading_plusleading_plusstring+999"+999"✓ all 6 fields

All ten rows passed all six checks (60/60). The CSV contains 10 data rows and 6 columns. The key distinction is between R01 (score_value_type=number, score_token=42) and R03 (score_value_type=string, score_token=42): both have the same source token 42, but different JSON types. Without score_value_type, these two rows would be indistinguishable in the CSV. The score_sign_marker column separates R02 (leading_plus) and R04 (leading_minus) from R01 (none), enabling direct filtering by sign without inspecting each cell's first character.

The exporter code

import json, csv, hashlib, re
from pathlib import Path

SCHEMA = ["id", "score_kind", "score_sign_marker",
          "score_value_type", "score_token", "score_json"]

def classify_sign_token(raw_text, val):
    """Classify a JSON value for the sign-token column policy."""
    if isinstance(val, bool):
        return "boolean", "none", "boolean", raw_text, json.dumps(val)
    if isinstance(val, (int, float)):
        if raw_text.startswith("-"):
            return "number_negative", "leading_minus", "number", raw_text, json.dumps(val)
        return "number_unsigned", "none", "number", raw_text, json.dumps(val)
    if isinstance(val, str):
        if val.startswith("+"):
            return "string_leading_plus", "leading_plus", "string", raw_text, json.dumps(val)
        if val.startswith("-"):
            return "string_leading_minus", "leading_minus", "string", raw_text, json.dumps(val)
        return "string_no_sign", "none", "string", raw_text, json.dumps(val)
    if val is None:
        return "null", "none", "null", raw_text, json.dumps(val)
    raise ValueError(f"Unexpected type: {type(val).__name__}")

def export_explicit(source_path, csv_path, naive_path, evidence_path):
    with open(source_path, "r", encoding="utf-8") as f:
        raw_text = f.read()
    source_sha = hashlib.sha256(raw_text.encode("utf-8")).hexdigest()
    records = json.loads(raw_text)

    # Extract raw value tokens from source text
    token_pattern = re.compile(r'"score"\s*:\s*([^\s,}\]]+)')
    raw_tokens = token_pattern.findall(raw_text)

    rows = []
    for i, rec in enumerate(records):
        row = {col: "" for col in SCHEMA}
        row["id"] = rec["id"]
        val = rec["score"]
        raw_token = raw_tokens[i]
        if isinstance(val, str):
            token_for_classify = raw_token.strip('"')
        else:
            token_for_classify = raw_token
        kind, sign, vtype, token, jrepr = classify_sign_token(token_for_classify, val)
        row["score_kind"] = kind
        row["score_sign_marker"] = sign
        row["score_value_type"] = vtype
        row["score_token"] = token
        row["score_json"] = jrepr
        rows.append(row)

    with open(csv_path, "x", encoding="utf-8", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=SCHEMA)
        writer.writeheader()
        writer.writerows(rows)
    return rows, raw_text, records

The exporter extracts raw value tokens from the source text using a regex pattern before parsing, preserving the exact spelling including any + prefix. For string values, it strips the surrounding JSON quotes to get the actual string content for sign classification. isinstance(val, bool) is checked before isinstance(val, int) to avoid Python's True being misclassified as the number 1. The exporter uses open(csv_path, "x") for exclusive file creation, refusing to overwrite an existing output.

Read-back verification

The verification step reads the CSV back with csv.DictReader and compares every column against the parsed source. For each row it checks that score_kind, score_sign_marker, score_value_type, score_token and score_json match the expected values from the classification function. All ten rows passed all six checks (60/60).

def read_back_verify(csv_path, records, raw_tokens):
    with open(csv_path, "r", encoding="utf-8", newline="") as f:
        reader = csv.DictReader(f)
        csv_rows = list(reader)
    for csv_row, src_rec, raw_token in zip(csv_rows, records, raw_tokens):
        val = src_rec["score"]
        if isinstance(val, str):
            token_for_classify = raw_token.strip('"')
        else:
            token_for_classify = raw_token
        exp_kind, exp_sign, exp_type, exp_token, exp_json = classify_sign_token(token_for_classify, val)
        assert csv_row["score_kind"] == exp_kind
        assert csv_row["score_sign_marker"] == exp_sign
        assert csv_row["score_value_type"] == exp_type
        assert csv_row["score_token"] == exp_token
        assert csv_row["score_json"] == exp_json

Why JSON forbids leading plus in numbers

Per RFC 8259 section 6, the JSON number grammar is: minus int [ frac ] [ exp ]. The minus production is the literal character - — there is no plus production for the sign. A number like 42 is valid; -17 is valid; +42 as a number is not. This is a deliberate grammar choice: JSON inherits the minimal number syntax from ECMAScript, where the unary + operator exists in expressions but not in literals.

This means that in valid JSON, a leading + sign is a reliable marker that the value is a string. The score_sign_marker column leverages this: any row with score_sign_marker=leading_plus is guaranteed to have score_value_type=string. However, the reverse is not true — a string like "42" has no sign marker (score_sign_marker=none) and is indistinguishable from the number 42 by token alone. Only the score_value_type column separates them.

Distinct from the numeric-token sign/notation guide. The positive/negative and scientific notation guide classifies JSON numbers by sign and notation (decimal vs scientific). This guide classifies values by their sign marker and JSON type, specifically to distinguish numbers from string lookalikes that share the same digits. A value like -17 (number) and "-17" (string) have the same sign but different types — the sign/notation guide would not catch this collision.

Distinct from the falsy-value guide. The falsy-value guide distinguishes number 0, boolean false, and string "0". This guide adds the sign-marker axis: +0 (string with leading plus), 0 (number), and "0" (string without sign) are three distinct values that the falsy-value guide would not differentiate by sign.

Reproduce the local example

These are independent Python examples, not measurements of the browser converter. Download the input, exporter, saved CSV, naive CSV and execution evidence into one folder. Use Python 3.12 or later and a new output filename.

Original JSONPython exporterExplicit CSVNaive CSVExecution evidence
python leading-plus-to-csv.py

The exporter reads at most 1,000,000 bytes and rejects inputs over that demonstration policy. That limit is a code policy, not a measured browser limit. The output uses UTF-8 and CRLF record endings. Exclusive file creation prevents overwriting an existing file. Faithful text conversion does not neutralize spreadsheet formulas, so use a suitable text import workflow for untrusted data.

Question: Can I distinguish numbers from string lookalikes without the value_type column?

You can inspect each CSV cell for a leading + sign: if the cell starts with +, it must have been a string in JSON (because JSON numbers cannot have a leading plus). But this approach fails for strings like "42" and "0" that have no sign prefix — they are indistinguishable from the numbers 42 and 0 without the score_value_type column. You can also try numeric conversion (e.g. int(cell)) to guess whether a cell was originally a number, but this fails for strings that contain valid numeric text: int("42") succeeds, giving a false positive. The five-column policy provides the typed, directly comparable fields that a spreadsheet user can filter on without guessing, while the score_token column carries the exact source spelling for provenance and the score_json column carries the canonical JSON serialization for reconstruction.