Skip to content

Bug: Case-insensitive key matching fails for structs with 17+ fields #568

@arkus7

Description

@arkus7

Summary

goccy/go-json fails to perform case-insensitive JSON key matching (as specified by encoding/json) when the target struct has 17 or more visible JSON fields (fields with a json tag that is not "-"). Structs with 16 or fewer fields work correctly.

Versions Affected

All tested versions are affected:

  • v0.9.7
  • v0.10.5

Expected Behavior

Per the encoding/json specification:

To unmarshal JSON into a struct, Unmarshal matches incoming object keys to the keys used by Marshal (either the struct field name or its tag), preferring an exact match but also accepting a case-insensitive match.

goccy/go-json documents itself as a drop-in replacement for encoding/json. Case-insensitive matching should work regardless of the number of fields in the struct.

Reproduction

package main

import (
	"encoding/json"
	"fmt"

	goccy "github.com/goccy/go-json"
)

// 16 fields — case-insensitive matching works
type small struct {
	F1   string `json:"f1"`
	F2   string `json:"f2"`
	F3   string `json:"f3"`
	F4   string `json:"f4"`
	F5   string `json:"f5"`
	F6   string `json:"f6"`
	F7   string `json:"f7"`
	F8   string `json:"f8"`
	F9   string `json:"f9"`
	F10  string `json:"f10"`
	F11  string `json:"f11"`
	F12  string `json:"f12"`
	F13  string `json:"f13"`
	F14  string `json:"f14"`
	F15  string `json:"f15"`
	Name bool   `json:"name,omitempty"`
}

// 17 fields — case-insensitive matching FAILS
type large struct {
	F1   string `json:"f1"`
	F2   string `json:"f2"`
	F3   string `json:"f3"`
	F4   string `json:"f4"`
	F5   string `json:"f5"`
	F6   string `json:"f6"`
	F7   string `json:"f7"`
	F8   string `json:"f8"`
	F9   string `json:"f9"`
	F10  string `json:"f10"`
	F11  string `json:"f11"`
	F12  string `json:"f12"`
	F13  string `json:"f13"`
	F14  string `json:"f14"`
	F15  string `json:"f15"`
	F16  string `json:"f16"`
	Name bool   `json:"name,omitempty"`
}

func main() {
	// JSON key "Name" should match struct tag "name" (case-insensitive)
	input := []byte(`{"Name":true}`)
	fmt.Printf("Input: %s\n\n", input)

	// 16 fields - works
	var g1 small
	goccy.Unmarshal(input, &g1)
	var s1 small
	json.Unmarshal(input, &s1)
	fmt.Printf("[16 fields] goccy: Name=%v | encoding/json: Name=%v\n", g1.Name, s1.Name)

	// 17 fields - BUG: goccy fails to match
	var g2 large
	goccy.Unmarshal(input, &g2)
	var s2 large
	json.Unmarshal(input, &s2)
	fmt.Printf("[17 fields] goccy: Name=%v | encoding/json: Name=%v\n", g2.Name, s2.Name)
}

Output

Input: {"Name":true}

[16 fields] goccy: Name=true | encoding/json: Name=true
[17 fields] goccy: Name=false | encoding/json: Name=true

Test with ignored field - works when visible fields number is <= 16

Test case with 17 fields when one of them is ignored (`json:"-"`)
package main

import (
	"encoding/json"
	"fmt"

	goccy "github.com/goccy/go-json"
)

// 16 fields — case-insensitive matching works
type small struct {
	F1   string `json:"f1"`
	F2   string `json:"f2"`
	F3   string `json:"f3"`
	F4   string `json:"f4"`
	F5   string `json:"f5"`
	F6   string `json:"f6"`
	F7   string `json:"f7"`
	F8   string `json:"f8"`
	F9   string `json:"f9"`
	F10  string `json:"f10"`
	F11  string `json:"f11"`
	F12  string `json:"f12"`
	F13  string `json:"f13"`
	F14  string `json:"f14"`
	F15  string `json:"f15"`
	Name bool   `json:"name,omitempty"`
}

// 17 fields — case-insensitive matching FAILS
type large struct {
	F1   string `json:"f1"`
	F2   string `json:"f2"`
	F3   string `json:"f3"`
	F4   string `json:"f4"`
	F5   string `json:"f5"`
	F6   string `json:"f6"`
	F7   string `json:"f7"`
	F8   string `json:"f8"`
	F9   string `json:"f9"`
	F10  string `json:"f10"`
	F11  string `json:"f11"`
	F12  string `json:"f12"`
	F13  string `json:"f13"`
	F14  string `json:"f14"`
	F15  string `json:"f15"`
	F16  string `json:"-"`
	Name bool   `json:"name,omitempty"`
}

func main() {
	// JSON key "Name" should match struct tag "name" (case-insensitive)
	input := []byte(`{"Name":true}`)
	fmt.Printf("Input: %s\n\n", input)

	// 16 fields - works
	var g1 small
	goccy.Unmarshal(input, &g1)
	var s1 small
	json.Unmarshal(input, &s1)
	fmt.Printf("[16 fields] goccy: Name=%v | encoding/json: Name=%v\n", g1.Name, s1.Name)

	// 17 fields - works because of ignored field
	var g2 large
	goccy.Unmarshal(input, &g2)
	var s2 large
	json.Unmarshal(input, &s2)
	fmt.Printf("[17 fields] goccy: Name=%v | encoding/json: Name=%v\n", g2.Name, s2.Name)
}

Output

Input: {"Name":true}

[16 fields] goccy: Name=true | encoding/json: Name=true
[17 fields] goccy: Name=true | encoding/json: Name=true

Impact

This is a silent data loss bug - no error is returned, the field is simply left at its zero value. This makes it particularly dangerous because:

  1. It only manifests when a struct grows past 16 fields, which can happen during normal development
  2. It only affects requests that rely on case-insensitive matching, so it may not be caught by tests that use exact-case keys
  3. There is no error to catch - Unmarshal returns nil

I've discovered this in production when customers' API requests started failing after we added new fields to a struct that previously had 16 visible JSON fields.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions