Backend engineer reviewing Go JSON decoding to identify stale struct fields and retained map keys.

Go JSON Unmarshal Reused Structs: Prevent Stale Fields in Replacement Records

Mon, Nov 2, 2026

A successful json.Unmarshal answers a parsing-and-decoding question. It does not, by itself, answer the application question that matters when records are repeatedly ingested into reused Go objects: does the destination now describe only the current JSON document, under this application’s replacement defaults, or does it still contain values from the previous destination?

That distinction is easy to miss because all three layers can look reasonable in isolation. The JSON text may be syntactically valid. Go may decode it without error. The resulting struct may contain every value that was explicitly present in the new document. Yet omitted struct fields or omitted keys inside an already allocated map can still reflect the old destination. Go’s documented encoding/json behavior permits precisely that in the legacy implementation selected for this laboratory. For an existing non-nil map, Unmarshal reuses the map and keeps existing entries unless corresponding input members replace them; for structs, incoming members are matched and decoded rather than the destination being generically reset first.

This playbook isolates that boundary with a single-process fixture using Go 1.27.3, GOEXPERIMENT=nojsonv2, local structs and maps, and authored JSON strings. The supplied commissioning observation reports runtime.Version() as go1.27.3-X:nojsonv2 and PASS_13_CASES. I could inspect the exact Go 1.27.3 source and documentation, but the available execution environment for this research has Go 1.23.2 rather than Go 1.27.3, so I did not independently rerun the supplied fixture under its required toolchain. The commissioning result is therefore reported as supplied evidence, not represented as a new reproduction.

The operational policy is deliberately small. A Record has an id, integer quantity, nested labels map, and notes slice. Under the local replacement contract, an omitted field means that the replacement receives that Go field’s zero or nil value. Empty id and negative quantity fail the application gate. null, one type-error case, and one syntax-error case are controls. This is not an HTTP-input experiment, a duplicate-member experiment, a custom unmarshaler study, a JSON Merge Patch implementation, a concurrency protocol, or a comparison with another Go JSON implementation.

Define replacement before you inspect decoder behavior

The first review question is not “did Unmarshal work?” It is “what operation did the caller intend?” A replacement snapshot and a retained-state update can feed identical JSON into the same decoder and legitimately demand opposite verdicts.

For this playbook, replacement has an explicit local meaning:

The admitted Record should describe the current JSON document. A member present in the document supplies that field’s value. A member omitted from the document leaves the corresponding field at the zero or nil value obtained from a fresh Record.

That last sentence is application policy, not a rule of JSON syntax. RFC 8259 defines a JSON object as an unordered collection of name/value pairs and defines null as one of JSON’s values; it does not tell a Go application whether absence of a member should clear, retain, inherit, default, or reject some pre-existing business value.

This boundary is also distinct from proving that an API request contains exactly one complete JSON value. The neighboring Refonte Learning article on whole-body validation with Go JSON Decoder examines decoder consumption, trailing values, limits, and an HTTP mutation gate. The present laboratory begins after that transport question has been removed: each case supplies one authored in-memory JSON string and asks what the destination object contains after decoding. The existing article itself describes that whole-body problem as a different admission surface.

A reviewer therefore needs three terms kept separate:

Layer

Question

This playbook’s evidence

JSON syntax

Is the text a syntactically valid JSON value?

json.Unmarshal syntax result and the G09 control

Go destination semantics

What does encoding/json do to this particular existing Go value?

G01–G10

Application operation

Should old destination state survive when the new document omits it?

Replacement policy versus G12 retained-state policy

A green answer at one layer does not imply a green answer at the next.

Declare omission defaults before the first decode

The omission rule has to be written before observing the destination. Otherwise a stale result can be rationalized after the fact as an “implicit default.”

For this fixture, the zero value is the oracle: Record{ID: "B"} is the correct replacement result for {"id":"B"}. That means Quantity == 0, Labels == nil, and Notes == nil. The fact that another policy could choose retained values is not disputed; G12 demonstrates exactly that alternative. What matters is that a replacement reviewer can name the intended operation and compare the destination with an independently authored expected value.

That discipline also avoids a common semantic shortcut: “valid JSON means complete object.” RFC 8259 imposes no such application-level completeness rule. An object with only "id" is a valid JSON object; whether it is sufficient for this Record replacement is an application decision.

Read the legacy destination contract as an object-lifecycle contract

The relevant Go documentation is unusually direct. For a JSON object decoded into a non-nil map, Unmarshal reuses the existing map, keeping existing entries, then stores the input’s key/value pairs. For a slice, it reuses existing slice elements while decoding and has a special case for an empty JSON array: the destination becomes a new empty slice. For a struct, incoming object members are matched to struct fields.

Those rules matter because json.Unmarshal receives a destination, not an abstract “new record.” The application decides what object lifecycle that destination represents.

The exact Go 1.27.3 legacy source is guarded with //go:build !goexperiment.jsonv2. Its top-level Unmarshal first checks the entire byte slice for valid JSON syntax and returns immediately if that precheck fails; only after that does it initialize decoding into the supplied destination. Later, object decoding distinguishes map and struct destinations. A nil map is allocated, but an existing map is not cleared first; for a struct, the decoder finds the field corresponding to each supplied key and decodes into it.

That source explains the fixture’s shape without turning implementation detail into a broader guarantee than the documentation gives. The review conclusion is not that every decode always retains history. The conclusion is narrower: reusing a destination whose omitted state matters is incompatible with this local replacement policy unless the caller establishes the required reset or fresh-candidate boundary.

This is established behavior, not a behavior claimed to have appeared in 2026. The source is pinned here because the experiment pins Go 1.27.3 with the jsonv2 experiment disabled.

Prepare records whose old state is easy to detect

A good stale-state fixture makes historical values conspicuous. The old record is deliberately noisy:

Record{
    ID:       "A",
    Quantity: 7,
    Labels:   map[string]string{"legacy": "keep", "zone": "old"},
    Notes:    []string{"old"},
}

The next partial document is only:

{"id":"B"}

Under the declared replacement contract, the independent expectation is:

Record{ID: "B"}

Any surviving 7, legacy, zone, or "old" is therefore evidence that the result describes more than the current document under this policy.

The fixture serializes the old destination before mutation:

before := encoded(r)

That is important because retaining another reference to the same map would not preserve an immutable snapshot. Go’s language specification says that non-nil map and slice values contain references to underlying data that may be shared. Assignment replaces the value stored in a variable; where that value contains a reference, assignment copies the reference rather than recursively copying the referenced data.

The same principle appears in a different storage-lifetime problem in Refonte Learning’s article on ownership of reused Go buffer bytes. That article concerns borrowed byte storage and bytes.Buffer, not logical JSON destination state, so its queue experiment should not be imported here. The common review lesson is only that a new variable or struct assignment does not automatically imply independent referenced storage.

A shallow struct copy still shares nested storage

This is why the demonstrated repair is not:

candidate := current

If current.Labels is populated, both struct values initially refer to the same map. If current.Notes has backing storage, copying the slice value copies its descriptor, not an independent backing array. Go’s specification explicitly describes maps as references to implementation-specific map data and slices as descriptors referring to an underlying array that may be shared.

The fixture instead uses:

var candidate Record

That gives the decode a zero-valued Record: empty string, zero integer, nil map, nil slice. It makes the replacement oracle easy to review and avoids claiming that this laboratory implements a general-purpose deep-copy mechanism.

Run the thirteen-case local qualification fixture

Save the following source exactly as backend_json_fixture.go. The required invocation is:

go version
go env GOOS GOARCH
GOEXPERIMENT=nojsonv2 go run backend_json_fixture.go > backend_json_results.json

Record the final command’s exit status and stderr as well as stdout. The source requires the GOEXPERIMENT environment variable to equal nojsonv2, reads backend_json_fixture.go from its current directory, hashes those bytes, and emits its report only after every assertion has passed.

package main

import (
    "crypto/sha256"
    "encoding/json"
    "fmt"
    "os"
    "reflect"
    "runtime"
)

type Record struct {
    ID       string            json:"id"
    Quantity int               json:"quantity"
    Labels   map[string]string json:"labels"
    Notes    []string          json:"notes"
}

func old() Record {
    return Record{"A", 7, map[string]string{"legacy": "keep", "zone": "old"}, []string{"old"}}
}

func encoded(v any) string {
    b, err := json.Marshal(v)
    if err != nil {
        panic(err)
    }
    return string(b)
}

func kind(err error) string {
    if err == nil {
        return "nil"
    }
    return fmt.Sprintf("%T", err)
}

func require(ok bool, message string) {
    if !ok {
        panic(message)
    }
}

// This local replacement contract permits omitted fields and their zero values.
// It is neither a complete business schema nor a concurrent publication protocol.
func replace(dst Record, body string) (Record, error) {
    var candidate Record
    err := json.Unmarshal([]byte(body), &candidate)
    if err != nil {
        return candidate, err
    }
    if candidate.ID == "" || candidate.Quantity < 0 {
        return candidate, fmt.Errorf("local record policy rejected candidate")
    }
    dst = candidate
    return candidate, nil
}

func main() {
    require(os.Getenv("GOEXPERIMENT") == "nojsonv2", "run with GOEXPERIMENT=nojsonv2")
    source, err := os.ReadFile("backend_json_fixture.go")
    if err != nil {
        panic(err)
    }
    rows := []map[string]any{}
    add := func(id, policy, before string, after, expected any, err error, wantError string) {
        require(kind(err) == wantError, id+": error class")
        require(reflect.DeepEqual(after, expected), id+": destination mismatch")
        rows = append(rows, map[string]any{"id": id, "policy": policy, "before": before,
            "after": encoded(after), "expected": encoded(expected), "error_class": kind(err), "pass": true})
    }
    partial := {"id":"B"}
    full := {"id":"B","quantity":2,"labels":{"zone":"new"},"notes":[]}
    badType := {"id":"B","quantity":"two"}
    freshExpected := Record{"B", 2, map[string]string{"zone": "new"}, []string{}}
    var r Record
    e := json.Unmarshal([]byte(full), &r)
    add("G01", "fresh complete baseline", encoded(Record{}), r, freshExpected, e, "nil")
    r = old()
    before := encoded(r)
    e = json.Unmarshal([]byte(partial), &r)
    retained := old()
    retained.ID = "B"
    add("G02", "reused destination violates declared replacement", before, r, retained, e, "nil")
    require(!reflect.DeepEqual(r, Record{ID: "B"}), "G02 must expose retained fields")
    r = Record{}
    before = encoded(r)
    e = json.Unmarshal([]byte(partial), &r)
    add("G03", "fresh destination with declared zero defaults", before, r, Record{ID: "B"}, e, "nil")
    m := map[string]any{"id": "A", "quantity": float64(7), "legacy": "keep"}
    before = encoded(m)
    e = json.Unmarshal([]byte(partial), &m)
    add("G04", "existing map keeps omitted keys", before, m,
        map[string]any{"id": "B", "quantity": float64(7), "legacy": "keep"}, e, "nil")
    r = old()
    before = encoded(r)
    e = json.Unmarshal([]byte(full), &r)
    add("G05", "nested map retains omitted key", before, r,
        Record{"B", 2, map[string]string{"zone": "new", "legacy": "keep"}, []string{}}, e, "nil")
    var freshMap map[string]any
    e = json.Unmarshal([]byte(partial), &freshMap)
    add("G06", "fresh map contains this document's keys", "null", freshMap,
        map[string]any{"id": "B"}, e, "nil")
    r = old()
    before = encoded(r)
    e = json.Unmarshal([]byte{"id":"B","quantity":null,"labels":null,"notes":null}), &r)
    add("G07", "null is type dependent", before, r, Record{"B", 7, nil, nil}, e, "nil")
    r = old()
    before = encoded(r)
    e = json.Unmarshal([]byte(badType), &r)
    add("G08", "type error may leave a partial mutation", before, r, retained, e, "*json.UnmarshalTypeError")
    r = old()
    before = encoded(r)
    e = json.Unmarshal([]byte{"id":"B","quantity":), &r)
    add("G09", "syntax precheck control in this baseline", before, r, old(), e, "*json.SyntaxError")
    r = old()
    before = encoded(r)
    candidate, e := replace(&r, badType)
    require(reflect.DeepEqual(candidate, Record{ID: "B"}), "G10 rejected candidate remains observable")
    add("G10", "discard fresh candidate on decode error", before, r, old(), e, "*json.UnmarshalTypeError")
    r = old()
    before = encoded(r)
    _, e = replace(&r, partial)
    add("G11", "publish only admitted fresh candidate locally", before, r, Record{ID: "B"}, e, "nil")
    r = old()
    before = encoded(r)
    e = json.Unmarshal([]byte(partial), &r)
    add("G12", "intentional local retained-field policy; not JSON Merge Patch", before, r, retained, e, "nil")
    r = old()
    before = encoded(r)
    candidate, e = replace(&r, {"id":"B","quantity":-1})
    require(candidate.Quantity == -1, "G13 policy rejection must follow a successful decode")
    add("G13", "discard fresh candidate on local policy error", before, r, old(), e, "*errors.errorString")
    require(len(rows) == 13, "case count")
    report := map[string]any{"result": "PASS_13_CASES", "go_version": runtime.Version(),
        "go_experiment": os.Getenv("GOEXPERIMENT"), "source_sha256": fmt.Sprintf("%x", sha256.Sum256(source)),
        "rows": rows, "scope": "single-threaded local records; no HTTP, persistent writes, custom UnmarshalJSON or json/v2"}
    enc := json.NewEncoder(os.Stdout)
    enc.SetIndent("", "  ")
    if err := enc.Encode(report); err != nil {
        panic(err)
    }
}

The fixture contains thirteen explicit IDs and makes failure loud. add verifies both the exact error class and reflect.DeepEqual(after, expected) before appending a passing row. Additional require calls prove the stale-state condition in G02, inspect rejected candidates in G10 and G13, and verify the total row count. A panic therefore prevents the success ledger from being emitted as though qualification had completed.

The supplied commissioning observation is PASS_13_CASES on go1.27.3-X:nojsonv2. Because my available runtime is Go 1.23.2, that observation was not independently rerun here. The Go 1.27.3 source does, however, confirm the legacy build boundary and the syntax-precheck, map-reuse, and error semantics that the cases exercise.

The case inventory is best read as a compact qualification matrix:

ID

Question

Expected destination after call

Error class

G01

Fresh full struct baseline

{"id":"B","quantity":2,"labels":{"zone":"new"},"notes":[]}

nil

G02

Partial JSON into old struct

old values retained except id

nil

G03

Partial JSON into zero struct

{"id":"B","quantity":0,"labels":null,"notes":null}

nil

G04

Partial JSON into existing map

omitted map entries retained

nil

G05

Full top-level struct, existing nested map

legacy retained inside labels

nil

G06

Partial JSON into nil map

only "id":"B"

nil

G07

null across int/map/slice

int retained; map and slice nil

nil

G08

Type mismatch into old struct

partial mutation possible

*json.UnmarshalTypeError

G09

Malformed JSON

old destination unchanged in this baseline

*json.SyntaxError

G10

Type mismatch into fresh candidate

published destination remains old

*json.UnmarshalTypeError

G11

Valid partial replacement candidate

published destination becomes zero-default B

nil

G12

Intentional retained-state policy

retained old fields accepted

nil

G13

Valid decode, local negative-quantity rejection

published destination remains old

*errors.errorString

The final process exit still matters. A JSON file containing some rows is not sufficient if execution later panicked; conversely, the source as written emits its report only after all assertions, so an empty or truncated stdout file is refusal evidence, not a partial PASS.

Compare omitted fields in reused and fresh destinations

G01 through G06 isolate the difference between input membership and destination history.

G01 establishes the ordinary full decode into a fresh struct:

{
  "id": "B",
  "quantity": 2,
  "labels": {"zone": "new"},
  "notes": []
}

The expected notes value is an empty non-nil slice, because Go documents the empty-JSON-array special case as replacing a slice with a new empty slice.

G02 then reuses the old struct with only {"id":"B"}. The expected and observed commissioning snapshot is logically:

{
  "id": "B",
  "quantity": 7,
  "labels": {
    "legacy": "keep",
    "zone": "old"
  },
  "notes": ["old"]
}

Nothing is wrong with the JSON. Nothing is wrong with Unmarshal relative to its documented contract. The mismatch is between destination reuse and the declared replacement defaults.

G03 proves the corresponding replacement behavior by decoding the same partial document into Record{}:

{
  "id": "B",
  "quantity": 0,
  "labels": null,
  "notes": null
}

G04 repeats the distinction at the top-level map layer. Go explicitly documents that a non-nil destination map is reused and existing entries are kept. The old map contains quantity and legacy; input supplies only id; both omitted entries remain.

G06 supplies the counter-control: a nil map is allocated for the new object, so the result contains only the member supplied by this document.

This issue is separate from member-name ambiguity. A JSON text can have unique names and still decode into a destination carrying stale data from yesterday’s object. Refonte Learning’s duplicate-name validation before JSON decoding addresses whether repeated names in one JSON object are preserved or rejected before normal decoding collapses them. Here the authored inputs have a different problem: omission does not imply destination reset.

Even an explicitly supplied object can retain a nested key

G05 is the case reviewers should keep in mind when someone says, “But the new JSON supplies every top-level field.”

The input is:

{
  "id": "B",
  "quantity": 2,
  "labels": {"zone": "new"},
  "notes": []
}

The old Labels map already contains:

map[string]string{
    "legacy": "keep",
    "zone":   "old",
}

Because the destination map is non-nil, the nested object is decoded into that existing map. "zone":"new" overwrites the existing zone entry, while the omitted legacy key survives. Go documents this map behavior directly.

So G05 ends with:

{
  "id": "B",
  "quantity": 2,
  "labels": {
    "legacy": "keep",
    "zone": "new"
  },
  "notes": []
}

G01, using a fresh destination, ends with only:

"labels": {"zone":"new"}

This is the nested version of the same lifecycle problem. “Every top-level field was present” does not establish replacement semantics for a referenced value stored inside one of those fields.

Treat null according to the Go destination type

G07 prevents another dangerous simplification: treating null, omission, {}, and [] as four spellings of “clear.”

The input is:

{
  "id": "B",
  "quantity": null,
  "labels": null,
  "notes": null
}

The old destination begins with Quantity == 7, a non-nil labels map, and a non-nil notes slice.

Go 1.27.3 documents type-dependent null handling. JSON null sets an interface, map, pointer, or slice destination to nil. For other Go types, null has no effect and does not itself produce an error.

Therefore the expected record is:

Record{
    ID:       "B",
    Quantity: 7,
    Labels:   nil,
    Notes:    nil,
}

id changes because a string was supplied. quantity remains 7 because int is one of the Go value types on which null has no effect under this legacy contract. labels becomes nil because it is a map. notes becomes nil because it is a slice.

That result should change a review decision. Under the local replacement policy, a caller cannot say “we explicitly sent null, therefore every field was cleared.” The outcome depends on the destination type.

An empty object is different again: decoding {} into an existing non-nil map does not remove its existing entries under the documented map rule. An empty array is also different: for slices, Go’s special case replaces the destination with a new empty slice.

This playbook does not broaden into presence wrappers, nullable domain types, or standardized patch formats. The only required conclusion is that omission and null must be interpreted against the authored application policy and the actual Go destination type.

Inspect the destination after a decode error

A non-nil decode error is an admission stop, but it does not imply that the destination is pristine.

G08 supplies:

{"id":"B","quantity":"two"}

into the old record. "B" is appropriate for the string field, so ID can change. "two" is not appropriate for the int field, so Go records an *json.UnmarshalTypeError.

The Go documentation says that when a JSON value is inappropriate for the target type, Unmarshal skips that field and completes unmarshaling as best it can; if no more serious error occurs, it returns the earliest UnmarshalTypeError. It also explicitly warns that processing of all fields after the problematic one is not guaranteed.

For this authored ordering and supplied commissioning baseline, G08’s destination is:

{
  "id": "B",
  "quantity": 7,
  "labels": {
    "legacy": "keep",
    "zone": "old"
  },
  "notes": ["old"]
}

That is useful evidence because it disproves a simplistic rollback assumption. json.Unmarshal does not promise transactional object mutation around type errors.

G09 is deliberately different. Its input is syntactically incomplete:

{"id":"B","quantity":

The Go 1.27.3 legacy source calls checkValid before initializing destination decoding specifically to avoid filling out half a data structure and only then discovering a syntax error. Therefore the old record remains unchanged in this baseline when *json.SyntaxError is returned.

The operational rule is still the same for both cases: do not publish the candidate after a decode error. The internal mutation pattern explains why fresh-candidate admission is safer to review, but a caller need not build business policy around the exact partial shape of an errored destination.

Do not extrapolate a single field-order result

G08 is evidence for this exact authored document, not a license to predict every reordered document.

The documentation says decoding proceeds “as best it can” after a type mismatch and expressly says that it is not guaranteed that every remaining field will be unmarshaled. That means a review should not invent a matrix such as “all fields before the bad field change, all fields after it remain old” unless a separate contract says so. The public documentation does not.

Likewise, G09’s no-mutation syntax-error behavior is tied here to the legacy Go 1.27.3 path whose source performs a whole-input syntax precheck. The fixture explicitly excludes custom UnmarshalJSON, and no result in this playbook should be generalized to custom decoding callbacks.

An error path should therefore be judged by a simpler boundary: the destination is not admitted. If an investigator needs the partial candidate for diagnostics, that is observation, not publication authority.

Admit a fresh candidate before local replacement

G10, G11, and G13 turn the object-lifecycle analysis into a reviewable admission pattern:

func replace(dst Record, body string) (Record, error) {
    var candidate Record

    err := json.Unmarshal([]byte(body), &candidate)
    if err != nil {
        return candidate, err
    }

    if candidate.ID == "" || candidate.Quantity < 0 {
        return candidate, fmt.Errorf("local record policy rejected candidate")
    }

    dst = candidate
    return candidate, nil
}

The first property is isolation from prior record state. candidate begins as the zero value, so omissions naturally receive the zero/nil defaults declared by this local replacement policy.

The second property is ordering. Decode qualification occurs before application qualification. Publication into *dst occurs only after both pass.

G10 feeds the bad type document to replace. The fresh candidate can become Record{ID:"B"} before the UnmarshalTypeError is returned; the fixture explicitly asserts that rejected candidate detail. But the destination passed as dst remains the old A record because the assignment is never reached.

G11 feeds only:

{"id":"B"}

The decode succeeds. ID is non-empty. Quantity is zero, which is allowed because the policy rejects only negative values. The assignment therefore publishes:

Record{ID: "B"}

That is exactly the declared replacement semantics.

G13 shows why decode success is not enough. Its document is syntactically and type-valid:

{"id":"B","quantity":-1}

The fresh candidate contains Quantity == -1; the fixture asserts that fact. Then the authored local policy returns an error, so the old destination remains unchanged.

There are two evidence surfaces here. Candidate-specific assertions for G10 and G13 live inside the fixture through require. The JSON row records the published destination passed to add. Keeping those separate makes it clear that “candidate was partly populated” is not equivalent to “record was replaced.”

No goroutine, durable store, message bus, database transaction, event publication, or external write is exercised. Assignment to one local Record is the entire publication boundary being qualified.

Keep intentional retained-state updates explicit

G12 performs the same direct decode as G02:

r = old()
e = json.Unmarshal([]byte{"id":"B"}), &r)

The resulting record retains Quantity, Labels, and Notes from A while changing the ID to B. Under the replacement contract, that is a defect and G02 says REPAIR.

Under a deliberately different local policy (“supplied members update this retained record; omitted members remain”), the same Go behavior can be intentional. G12 labels that case explicitly.

That comparison is valuable because it prevents an overcorrection. Destination reuse is not intrinsically wrong. It is wrong when it contradicts the operation the application claims to perform.

The reviewer should therefore require the operation name at the call site or surrounding contract:

  • Replacement snapshot: omissions receive declared replacement defaults; use an independently fresh candidate and publish only after admission.

  • Retained-state update: existing values may intentionally survive absent members; prove that this is the authored operation rather than an accidental consequence of object reuse.

Do not call the second policy JSON Merge Patch. RFC 8259 defines JSON syntax and values, not RFC 7396 patch semantics, and this fixture does not implement a standardized patch algorithm. Its type-dependent null behavior also comes from Go destination semantics, not a declaration that null has a universal patch meaning.

That distinction is the point of G12: the object snapshot alone cannot tell a reviewer whether retention is acceptable. The declared operation decides.

Reconcile the complete evidence before accepting a path

A useful qualification artifact is more than “the process did not panic.” It lets a reviewer reconcile the contract, source identity, runtime identity, case inventory, error classes, and destination snapshots.

For this fixture, acceptance evidence should establish all of the following:

The source is the reviewed backend_json_fixture.go, with the recorded source digest tied to those exact bytes.

The runtime reports the intended Go 1.27.3 identity and GOEXPERIMENT=nojsonv2.

There are exactly thirteen unique rows, G01 through G13.

Every row reports "pass": true.

  1. G02 differs from Record{ID:"B"} and visibly contains old values.

  2. G03 contains the zero/nil defaults required by the replacement contract.

  3. G04 and G05 preserve the expected old map entries.

  4. G06 contains only this document’s map members.

  5. G07 distinguishes integer null behavior from map and slice null behavior.

  6. G08 reports *json.UnmarshalTypeError with the documented partial-mutation possibility.

  7. G09 reports *json.SyntaxError and leaves the old destination unchanged in this selected baseline.

  8. G10 and G13 leave the published destination unchanged on candidate rejection.

  9. G11 publishes the admitted fresh candidate.

  10. G12 is classified as intentional retention rather than replacement.

  11. Exit status is zero and stderr contains no assertion panic.

If a team adds schema validation around these records, it remains a separate qualification layer. Refonte Learning’s article on JSON Schema extension and declared object contracts shows why an explicit acceptance set matters when validating object contracts. Schema validation can decide whether an object satisfies a declared schema; it does not retroactively clear a pre-existing Go map or reset omitted struct fields inside a destination object.

Preserve refusals without inventing a partial JSON report

The fixture’s reporting shape has an important operational consequence: it does not maintain a durable partial ledger as it runs.

Rows accumulate in memory. Only after all thirteen cases and the row-count assertion succeed does json.Encoder.Encode(report) write the JSON report to stdout.

Therefore:

  • zero exit plus a complete report is candidate PASS evidence;

  • nonzero exit is HOLD;

  • assertion panic on stderr is HOLD;

  • an empty output file is HOLD, not “zero failures”;

  • truncated JSON is HOLD, not a partial qualification record.

The report itself contains runtime.Version(), the experiment string, source SHA-256, rows, and the scope string. It does not include a timestamp, GOOS, or GOARCH, so the supplied command captures go version and go env GOOS GOARCH separately. Those external records should be archived beside stdout, stderr, and exit status if the qualification package needs them.

That is also where this research has a limit: I verified that the available local environment reports Go 1.23.2, so I did not substitute that runtime and relabel its result as a Go 1.27.3 qualification.

Choose ACCEPT, REPAIR or HOLD from the record contract

The decision should follow the operation and evidence, not simply whether json.Unmarshal returned nil.

Declared operation

Decode/policy status

Actual destination

Evidence quality

Decision

Replacement

Fresh candidate decodes and passes local gate

Matches independently authored zero/nil replacement oracle

Complete qualified ledger

ACCEPT

Replacement

Direct reused destination decodes successfully

Contains omitted old fields or old map keys

Complete evidence

REPAIR

Replacement

Decode error

Candidate may be partial; published destination withheld

Error class qualified

HOLD candidate; retain prior destination

Replacement

Decode succeeds, application gate fails

Candidate rejected; prior destination preserved

Policy rejection qualified

HOLD candidate

Replacement

Omission semantics are unspecified

Any

Even a green fixture cannot resolve policy

HOLD

Retained-state update

Direct reuse is the authored operation

Omitted values intentionally survive

Complete evidence for that policy

ACCEPT bounded behavior

Any

Unsupported destination types, custom unmarshaling, changed toolchain assumptions, or missing report

Unknown

Incomplete or out of scope

HOLD

Historical outputs

Original documents and identity mapping still exist

Reconstructed fresh candidates differ from stored outputs

Provenance sufficient

REVALIDATE

For a replacement path, the practical repair is not “call Unmarshal more carefully” in the abstract. It is to establish a destination whose initial state matches the replacement defaults, decode into it, run the independent admission checks, and only then assign it to the local destination.

That final assignment has ordinary Go value-assignment semantics. The specification says assignment replaces the variable’s stored value; if the assigned value itself contains references, those references are copied, not recursively cloned. In this fixture that is acceptable because the candidate’s map and slice were created as part of the candidate decode and are not obtained by shallow-copying the old record.

One limitation belongs prominently in the decision: this qualification explicitly selects GOEXPERIMENT=nojsonv2. The exact Go 1.27.3 legacy source carries the !goexperiment.jsonv2 build constraint. Therefore this baseline does not qualify a different implementation path. It should not be presented as a default-v2 comparison or migration result.

Recover affected records from original input evidence

Fixing the code path prevents a future replacement from inheriting stale destination state. It does not reconstruct historical truth by itself.

The recovery owner should first identify the precise ingestion path that reused destinations under a replacement contract. Then determine whether the original JSON documents are preserved with enough provenance to bind each document to the record identity that was produced from it.

Where provenance permits, revalidation should be deterministic:

  1. Freeze the affected code/version boundary and identify the records produced through it.

  2. Retrieve the original document for each record identity.

  3. Decode that document into a new zero-valued candidate using the qualified replacement path.

  4. Reapply the same independent application gate or the approved current revalidation rule.

  5. Compare the reconstructed candidate with the stored/derived record.

  6. Classify discrepancies by identity and field, especially omitted fields and nested map keys.

  7. Replace or correct historical records only through the system’s normal reviewed repair mechanism.

  8. Preserve the original evidence and the reconciliation result.

A code fix cannot recover a document that was never retained. It also cannot infer with confidence whether a surviving old value was intended by some unknown caller policy. Missing provenance therefore belongs in HOLD, not in a fabricated “best effort” replacement.

Error ownership should remain separate from this object-state qualification. Refonte Learning’s article on classification policy for joined Go errors makes the same architectural distinction in another domain: the Go error representation can expose facts, while the application still owns the action policy. This fixture uses neither errors.Join nor that article’s classification mechanism; the link is useful only for assigning ownership of error-time decisions rather than smuggling them into decoder semantics.

Keep rollback distinct from historical data repair

Restoring a previously reviewed implementation is a deployment operation. Reconstructing records from original JSON is a data-repair operation. This fixture performs neither.

A rollback may stop a newly introduced path from running, but it does not make already derived records correct. Conversely, a historical reconciliation can repair stored records while the faulty ingestion code remains live, which would allow new contamination to continue.

Keep the artifacts distinct:

  • implementation disposition: ACCEPT, REPAIR, or HOLD;

  • deployment action: whatever the team’s normal reviewed process authorizes;

  • historical record status: unaffected, reconstructable, corrected, quarantined, or unrecoverable from available provenance.

When an original document no longer exists, do not relabel the current output as “revalidated.” When the mapping between an original document and a derived record is uncertain, quarantine that uncertainty explicitly. A repair process is credible only when it preserves the distinction between what can be reconstructed and what has been lost.

Assign the revalidation boundary to a named owner

This fixture should not become an excuse for endless retesting of unrelated backend behavior. Requalification belongs to assumptions that can change the meaning of the evidence.

Assign a backend owner for the destination contract and require revalidation when any of these change:

  • the Record fields or JSON tags;

  • Labels or Notes change type;

  • nested maps become structs, pointers, interfaces, wrappers, or other representations;

  • the omission rule changes from zero/nil replacement to required-field rejection or retention;

  • the application gate changes, including the ID or quantity rules;

  • custom UnmarshalJSON behavior is introduced;

  • the selected Go toolchain or GOEXPERIMENT condition changes;

  • candidate construction changes from a zero value to a reused or shallow-copied value;

  • publication moves from the local *dst = candidate assignment to another mechanism whose atomicity or visibility matters.

Each trigger invalidates some premise of the bounded evidence. For example, turning Quantity int into *int changes the meaning of null; Go documents null as setting a pointer to nil, whereas it has no effect on a plain integer. Replacing Labels map[string]string with another representation can eliminate or alter the map-retention condition. Introducing custom unmarshaling exits the fixture’s declared scope.

By contrast, a change to an unrelated HTTP timeout should not force this object-lifecycle fixture to become a transport test. A queue implementation change should not pull byte-buffer aliasing into the record oracle. Reviewability improves when the evidence boundary stays small.

A release handoff for the qualified path should contain:

  1. the reviewed replacement policy in plain language;

  2. the exact fixture source;

  3. source SHA-256 from the qualifying run;

  4. go version;

  5. go env GOOS GOARCH;

  6. GOEXPERIMENT=nojsonv2;

  7. stdout JSON report;

  8. stderr capture;

  9. process exit status;

  10. confirmation of thirteen unique case IDs;

  11. confirmation of expected error classes;

  12. explicit acceptance of G11’s fresh-candidate publication behavior;

  13. explicit rejection or repair of G02-style reuse where the operation is replacement;

  14. identification of any retained-state paths intentionally classified like G12;

  15. the owner and scope of historical revalidation, if prior records may be affected.

The technical argument ends at that handoff. A replacement path is ready only when its object lifecycle implements the declared defaults and the qualification evidence is reproducible. A retained-state path is ready only when retention is intentional and named. Anything else is HOLD rather than an invitation to infer semantics from whatever happened to remain in memory.

Build backend validation judgment through practical projects

The durable engineering skill here is not memorizing that one Go map can retain a key. It is learning to separate representation, lifecycle, and admission policy: what the input says, what the decoder does to a destination, and what the application is willing to publish.

That same review habit applies across backend work. An API boundary needs an authored contract. Tests need independent expectations rather than outputs copied back into fixtures. Error results need an owner for the decision they trigger. State transitions need an explicit point after which a candidate becomes the accepted record. The thirteen-case fixture is useful because it keeps those responsibilities visible without pretending to be a production integration.

Refonte Learning’s Backend Development programme currently lists RESTful APIs and microservices, testing and debugging backend services, database work, deployment topics, and a capstone project among its stated curriculum areas. Those are relevant contexts in which to practice the validation judgment used here. The programme page describes Node.js, Express, MongoDB and SQL among its technologies; it does not establish Go or this specific fixture as a syllabus module, so no such claim is implied.

For this playbook’s bounded decision, the release rule remains concrete: ACCEPT a qualified fresh candidate when replacement defaults are declared and satisfied; REPAIR reused-destination replacement paths that preserve stale state; HOLD on decode failure, policy failure, unsupported assumptions, or incomplete evidence; and REVALIDATE historical outputs from their original documents where provenance makes an honest reconstruction possible.