Skip to content
datahacks.dev
JSON

JSON in Go Java Rust C Sharp PHP Ruby Compared Side by Side

One order document read in Go, Java, Rust, C#, PHP, Ruby, Python, Node and Bash: unknown keys, case, nulls, big ints, depth, duplicates and strictness.

18 min read
Summarize in AI
Flat illustration of a tree of nested objects beside the document it came from, with the JSON logo.

Every language can read JSON, and the defaults differ enough that the same document means different things in different places. This matters whenever two services in different languages exchange it, whenever a team moves from one to another, and whenever a body is validated by one parser and used by a second. The differences are small, specific and easy to test, and they are not in the tutorials.

I wrote the same order document and the same list of edge cases in nine languages and ran them. This guide is the comparison, with links to the shorter article for each language that has the full tests.

The document and the questions

JSON
{"id": 9007199254740993, "customer": {"name": "Ana", "email": null},
 "items": [{"sku": "A1", "qty": 2, "price": 9.5}, {"sku": "B2", "qty": 1, "price": 12}],
 "status": "paid", "extra": {"gift": true, "wrap": "blue"}, "created_at": "2026-01-02T03:04:05Z"}

The id is 2^53 + 1, the smallest integer a 64-bit double cannot represent. The customer's email is null, not missing. The second price is 12, not 12.0. Those three details turn out to separate most of the readers.

For each language the questions were: what happens to a key the model does not name, whether key matching is case sensitive, how null and missing differ, what happens to the big id, how deep a document may go, what happens with duplicate keys, and how strict the syntax is. A final set covers writing.

The cheat sheet

BehaviorGoJava (Jackson)Rust (serde_json)C# (STJ)
Unknown keyignoredexceptionignoredignored
SwitchDisallowUnknownFieldsFAIL_ON_UNKNOWN_PROPERTIES offdeny_unknown_fieldsUnmappedMemberHandling.Disallow
Key caseinsensitivesensitivesensitivesensitive (Web preset: insensitive)
Missing fieldzero valuenot runerror unless Option or defaultdefault; required makes it an error
JSON null for an intno-op (documented)not runnot runJsonException
Big id in typed fieldexact (int64)exact (long)exact (i64)exact (long)
Big id in dynamic valuefloat (any); exact with UseNumberexact (JsonNode long)exact (Value i64)double read loses it
Duplicate keylastlast (error with strict flag)struct: error; Value: lastlast (JsonDocument)
Depth limitnot run1,00012864
Trailing tokenserroraccepted by defaulterrorerror
BehaviorPHPRuby (json 2.9.1)PythonNode
Unknown keyno model (array or object)no model (Hash)no model (dict)no model (object)
Null vs missingnull vs unset (??)nil vs absent keyNone vs absent keynull vs undefined
Big idexact up to 64-bit intexact, unboundedexact, unboundedrounded to 9007199254740992
Duplicate keylastlastlastlast
Depth limit512100none on parse (RecursionError at 100,000)none (1,000,000 parsed)
Trailing commaerrorerrorerrorerror
Commentserroracceptederrorerror
NaNerrorerror (allow_nan opts in)acceptederror
Trailing tokenserrorerrorerrorerror

Two rows of the first table carry the most risk. Go's case-insensitive matching means a body with STATUS is accepted and one with both Status and status is ambiguous. And the unknown-key default splits the field: a consumer in Go reads past a new field, a consumer in Java throws on it.

Unknown keys

The same body, {"status":"paid","surprise":1}, produced:

Text
Go            Status = "paid", surprise dropped, no error
Java          UnrecognizedPropertyException: Unrecognized field "surprise"
Rust          Ok(Loose { status: "paid", .. })
C#            Status = null (property is PascalCase and the key is not), no error

The C# result deserves a second look: with default options the property Status did not receive the key status, and the record came back with null and no complaint. The unknown key and the case mismatch both fail silently, and the symptom is a half-empty object. Set a naming policy or the Web preset, and verify with a test that a field is not null (reading JSON in C Sharp with System.Text.Json defaults).

In all four, strictness is a switch, and each has a limit: Go's does not propagate through a custom UnmarshalJSON, Rust's does not work with flatten, Jackson's can be overridden per class with @JsonIgnoreProperties. Use strict readers for configuration and request bodies, tolerant readers for events and third-party responses (handling unknown JSON fields in Go goes deep on Go's, and event payloads in JSON and schema evolution explains why consumers should be tolerant).

Null and missing

Typed languages treat the pair differently:

  • Go: a pointer is nil for both a missing key and an explicit null, and a plain field cannot tell either from its zero value. A wrapper type or a map of raw messages is needed to distinguish them.

  • Rust: Option<T> is None for both. A nested Option<Option<T>> with a custom deserializer gives None, Some(None) and Some(Some(v)).

  • Java: a tree (JsonNode) tells them apart (get returns Java null for missing, a NullNode for JSON null; path returns a MissingNode).

  • C#: int with a JSON null throws JsonException; a missing int is 0. Use int? or required.

  • PHP, Ruby, Python and Node: the dynamic structures keep both: PHP array_key_exists against isset, Ruby key?, Python in, JavaScript in or hasOwnProperty.

If the API has PATCH semantics, this decides the design (partial updates and patch semantics). A reader that cannot distinguish absent from null has to be given extra code, or the contract has to say that null and absent mean the same.

Big numbers

The 16 digit id came out like this:

Text
Go typed int64                     9007199254740993
Go any / map[string]any            9.007199254740992e+15
Go Decoder.UseNumber               9007199254740993 (json.Number)
Java record long / JsonNode        9007199254740993
Rust i64 / Value                   9007199254740993 (as_f64: 9007199254740992.0)
C# long / JsonNode                 9007199254740993 (GetValue<double>: 9007199254740992)
PHP int                            9007199254740993
Ruby Integer                       9007199254740993
Python int                         9007199254740993
Node JSON.parse                    9007199254740992
jq 1.6                             9007199254740992

Typed fields were exact everywhere. The loss happens in dynamic values and in JavaScript, which has no integer type for JSON: Node, a browser, and anything that goes through double. The rule that avoids it is to send ids as strings. For 20 digit numbers the picture changes: PHP gave 1.2345678901234567E+19 (a float) unless JSON_BIGINT_AS_STRING was set, Go's any gave a float, and Python, Ruby and Java stayed exact (JSON security mistakes in apps and pipelines has the full table with the databases too).

Decimals follow a similar split. Jackson read 0.1000000000000000055511151231257827 as a double and printed 0.1 until USE_BIG_DECIMAL_FOR_FLOATS was on. .NET's decimal kept 28 digits. Ruby needed decimal_class: BigDecimal. Python's parse_float=Decimal works but has a trap of its own with huge exponents (handling untrusted JSON in Python safely). Money should be a string or integer minor units in the contract.

Depth and other limits

A limit on nesting protects the parser and the code after it:

Text
System.Text.Json    64   (65 throws JsonReaderException)
Ruby json           100  (101 throws NestingError)
serde_json          128  (200 throws "recursion limit exceeded")
jq 1.6              128  (140 refused)
PHP                 512  (512 throws "Maximum stack depth exceeded", 511 passes)
Jackson             1,000 (1,500 throws StreamConstraintsException; 900 passes)
Python json.loads   parses 5,000; RecursionError at 100,000
Node JSON.parse     parses 1,000,000; JSON.stringify fails from about 5,000
Go                  no failure in my tests (not pushed far)

The last two rows are the ones that surprise: a parser with no limit passes the problem to whatever walks the result (denial of service through deeply nested JSON). I did not push Go's decoder on depth, so I make no claim about its limit. Jackson also limits number length (1,000 digits) and string size, which is a better set of defaults than the others have, and a model for what a parser should do.

Syntax strictness

The strict spec says no trailing commas, no comments, no NaN, no single quotes, no leading zeros, no hex, no raw control characters in strings, and a BOM is not part of the text. I ran those through Python and Node and sampled the rest:

Text
                         Python   Node   Ruby 2.9.1   C#     Java   Rust
trailing comma           error    error  error        error  error    error
comment                  error    error  ACCEPTED     error  not run  not run
NaN                      ACCEPTED error  error        error  error  error
single quotes            error    error  not run      -      -      -
leading zero (01)        error    error  not run      -      -      -
raw tab in a string      error    error  not run      -      -      -
lone surrogate escape    accepted accepted not run     -      -      -
BOM at the start of text error    error  not run      -      -      -

The cells I did not run are marked rather than guessed. The readers that accept something the spec forbids are the ones where two systems disagree: Python reads NaN, which Node refuses; Ruby 2.9.1 reads a comment that every other parser refused. Anything that validates in one language and then hands the text to another should use the stricter of the two for validation. Python's json.loads also accepts a lone surrogate escape ("\ud800"), producing a string that raises UnicodeEncodeError when written as UTF-8, and Node returns the same lone surrogate.

Writing

Output differs more than input:

WritingResult
Java Instant with the Java time module1767323045.000000000 (a number) until WRITE_DATES_AS_TIMESTAMPS is disabled
C# DateTimeOffset"2026-01-02T03:04:05+00:00"
Rust f64 12.012.0
PHP float 1.01 unless JSON_PRESERVE_ZERO_FRACTION
Go structkeys in field order; omitempty drops zero values; null for nil pointers
Ruby Time"2026-01-02 03:04:05 UTC" (its to_s, not ISO 8601)
PHP array with a gap after array_filter{"0":1,"2":2} (an object, not a list)
PHP invalid UTF-8false and an error message, unless a flag substitutes
Ruby / PHP / Python NaNRuby raises GeneratorError; PHP returns false; Python writes NaN

Python's json.dumps writes NaN for a NaN float, which is not valid JSON (handling untrusted JSON in Python safely covers allow_nan). Dates are the most common cross-language disagreement: the same field is a number in one service and an ISO string in another because of a serializer default nobody chose.

What each reader taught me

Tables compress the results, and the reasons live in the details. These are the notes I would give a colleague who is about to write a service in each language, in the order I ran them.

Go: forgiving by design

Go's encoding/json is built to get something out of whatever it is given. It matches keys without regard to case, ignores members it has no field for, leaves a field alone when the key is absent, and carries on filling the remaining fields after a type error. Each of these is documented, and together they make decoding hard to fail by accident and easy to get wrong without noticing. A struct with a misspelled tag decodes with no error and an empty field. A client that sends "Status" and one that sends "status" both work, which is nice until the two keys arrive in one body.

The practical advice is to put the strictness where you need it, with a Decoder and DisallowUnknownFields, and to remember that an error return means the value is partial and must be discarded. Pointer fields give you "present but null" only up to a point, because a pointer is nil for both an absent key and a null. The big-number story is also a Go story: typed fields are exact, any is a float64, and a decoder with UseNumber is the way out. Of all the readers, Go is the one where I most often found a result that looked right and was not.

Java: strict by default, with a long list of switches

Jackson is the opposite temperament. It throws on an unknown property, it is case sensitive, it has depth and number-length limits out of the box, and its error messages name the setting that caused them. The cost of that is configuration. A real application ends up with a mapper that has a dozen features switched, and the mapper is shared, so the configuration is a contract. The trap is that a few defaults are not what a web developer expects: dates are written as numbers until you disable timestamps, trailing tokens after the first value are accepted, and duplicate keys are silently resolved until you enable the detection flag.

If you work with Jackson, put the mapper construction in one place, give it a test that round trips a document with a date, a null and a decimal, and review the feature list whenever the library is upgraded. The limits it enforces since 2.15 are the best defaults in this comparison and are worth copying into your thinking about other languages.

Rust: types as the validation layer

serde_json reads into types, and the types do most of the validating. Integer ranges are enforced with no silent truncation, a string is never accepted for a number, a missing field is an error unless you said otherwise, and errors carry a line and a column. Unknown keys are ignored by default, which surprised me a little given the rest of the character, and deny_unknown_fields is one attribute away. A repeated key in a struct is an error, while the same repeated key in a Value is last-wins, so the two modes of the same library disagree.

What Rust does not do for you is the distinction between null and absent: Option<T> is None for both, and you build the difference yourself when you need it. The recursion limit of 128 is a sensible guard, and the library gives you a clear way to turn it off for trusted input, which is the right way around. For services that must refuse anything odd, the combination of strict types, deny_unknown_fields and range checks gets close to a schema with no extra library.

C#: the case trap

The first thing that happened in C# was that nothing happened. With default options, camelCase JSON did not bind to PascalCase properties, and the object came back with every value empty. That is the behavior that cost me the most time to understand, because there was no exception to read. Once the naming policy is set, System.Text.Json is strict and fast: syntax errors have positions, the depth limit is 64, trailing commas and comments are rejected, and required members raise when missing.

The sharp edges are in enums, which accepted a number that the enum does not define, and in the Web preset, which also loosened number parsing so that a string of digits was accepted for an integer. Setting options deliberately, and testing a fixture that contains a mistaken case, a string where a number belongs and an undefined enum value, is the habit that pays off. The unknown member handling and duplicate-key behavior are weaker than Jackson's, and in .NET 9 there is no switch to reject duplicate keys.

PHP: the null that means two things

PHP's json_decode returns null on failure and also null for the document null. The fix, JSON_THROW_ON_ERROR, is simple and not the default, and a lot of code in the world still checks the result against null. Past that, PHP is predictable: arrays or objects as you ask, big integers turning into floats unless you ask for strings, a depth of 512, last-wins on duplicates. The writing side has the quirks: an array becomes a list or an object depending on its keys, so a filtered array can change the shape of a response without any change in the code that built it.

Because PHP has no typed binding in the language, validation is a separate step, and that is where the problems are. Decode with the throw flag, validate with a schema or a mapper, and only then build objects.

Ruby: small, with a surprise in the parser

Ruby's JSON.parse is a small and pleasant API. Integers are unbounded, symbolize_names is the option everyone uses, and max_nesting of 100 is a good default. What I did not expect was that json 2.9.1 accepted a comment without any option. A parser that accepts more than the standard is the cause of most disagreement between systems, so if Ruby is the validator for a body that another language will later read, check what the version you run does with comments, and test it.

Python and Node: dynamic, and the big-number split

Python and Node need no models, so there is nothing to bind and nothing to forget. Python's json.loads accepted NaN, Infinity and a lone surrogate, and kept integers exact at any size. JavaScript's JSON.parse is strict about syntax and rounds every number to a double. That difference alone explains many "the ids do not match" bugs between a Python backend and a browser. Neither has a depth limit on the parse, and both fail later in recursive code, which is worth treating as part of the parser's behavior (JSON in JavaScript and Node done carefully and handling JSON in Python beyond json.loads cover the details).

Bash and jq: text in, text out

The shell has no JSON type, and jq is the tool that adds one. The traps there are about quoting and splitting: building a body with printf breaks on a quote and can be injected, and a for loop over jq -r output splits values on spaces and newlines. jq 1.6 also rounds big integers, which jq 1.7 and later avoid for literals that pass through unchanged. Pass values with --arg, read with mapfile or NUL separators, and test with -e (using JSON in Bash scripts safely).

A scenario: one order through four services

The differences stop being abstract when a document crosses services. Take an order created by a Python service, passed through a Node gateway and consumed by a Go billing service and a Java reporting job. Nothing in the diagram is unusual.

The Python service writes the order with json.dumps. The id is a large integer from a snowflake generator, and the customer's email is None. The output is {"id": 9007199254740993, ..., "email": null}, valid and exact.

The Node gateway parses the body to add a request id, and serializes it again. JSON.parse turns the id into the nearest double and JSON.stringify writes 9007199254740992. Nothing failed. The order now has a different id, one lower than the original, and the gateway's own logs show the new one. This is the part that turns a data problem into a debugging problem: every service that sees the document sees a plausible one.

The Go billing service decodes into a struct with an int64 id, so it reads 9007199254740992 exactly and stores it. A lookup by the original id in the database finds nothing. The same service's Status field is tagged status; a client library upgrade that starts sending Status also works, because Go matches without regard to case, and nobody notices that two spellings are now in circulation.

The Java reporting job reads the same events with Jackson. It throws on the first event that carries a field it does not know, because the producer added gift last week and the mapper was left at its default. The job fails at night, the on-call person adds @JsonIgnoreProperties, and the report for that night is a day late. When the job runs again it quietly reads the gateway's rounded id, so reports and billing agree with each other and not with the source.

None of these four steps is a bug in a library. Each is the documented default of a reasonable tool, and the failure comes from the combination. The fixes are the ones in the checklist: the id is a string in the contract, the gateway forwards bytes instead of parsing and re-serializing when it only needs to add a header, the consumers share a schema and a fixture folder, and each service decides on purpose whether unknown fields are errors. A schema check at the gateway would have rejected the numeric id on day one.

The lesson I take from the exercise is to be suspicious of any component that parses and re-serializes a document it does not own. Each pass is a chance to change a number, reorder keys, drop members it does not know and normalize strings. If a service only routes, let it route bytes.

Patterns that held across languages

Looking across all of them, a few rules survived every runtime I tried.

Read into a typed structure when you can, and keep a dynamic value for the part you do not control. Typed reads were exact for large integers in every language where the dynamic read was not, and typed reads produced the best error messages.

Decide strictness by who sends the data. Configuration files, request bodies from your own client and test fixtures should be strict. Events, third-party responses and anything that crosses a version boundary should tolerate additions.

Treat an error from the decoder as a rejected document. Go fills the fields it can and then returns an error, and most libraries leave partial state in some situations. The only safe behavior is to discard the result and answer with a 400.

Handle the id and the money in the contract. A 16 digit id sent as a number broke in Node and jq and survived everywhere else, which means it breaks at the first JavaScript client. Strings for ids and decimals, integer minor units or strings for money, ISO 8601 with an offset for time.

Put limits in front of the parser. Most readers in this comparison do not limit the size of what they read, and the depth limits range from 64 to none. Cap the body, cap the depth, and validate after (limiting depth and size of incoming JSON).

Test the odd cases once, in each language you run, and keep the fixtures. The folder of awkward documents is the cheapest form of documentation for how your stack behaves.

Choosing and testing

You cannot pick one behavior and make every language follow it, so the portable approach is a contract and a test:

  1. Write down the contract: key style, id and money types (strings), date format (ISO 8601 with offset), whether null and missing are equal, whether unknown keys are allowed.

  2. Keep a folder of sample documents, including the awkward ones from this guide: a 16 digit id, a null, a duplicate key, a STATUS in the wrong case, a document nested 200 deep.

  3. Run the same folder through each service's reader in CI and assert the result. A short test per service is enough.

  4. Validate at the edge with one stricter parser and let the rest trust it. A schema check (validating request JSON before it reaches the database) turns most of the differences into rejected requests.

Generated models save time on the first draft. The converters in the tool set write Go, Java, Rust, C#, PHP and Python types from a sample: JSON to Go, JSON to Java, JSON to Rust, JSON to C# and JSON to PHP. Read the output with the defaults in this guide in mind.

One article per language

The specifications are RFC 8259 and ECMA-404. For a broad survey of parser differences with hundreds of test cases, see Nicolas Seriot's the JSONTestSuite behind "Parsing JSON is a Minefield". You can try a document in the JSON formatter to see how a browser reads it.

When a language comparison is not what you need

You have one language and one library. Then the question is that library's defaults, and the shorter article for it is the right place to start.

The data is validated by a schema first. A strict schema makes most of these differences moot, since a document that passes means the same everywhere.

The risk is not parsing. If the trouble is query speed, storage or shape, the comparison is the wrong tool (using JSON in application design without making a mess).

You need performance numbers. I measured behavior, not throughput. Benchmarks of JSON libraries depend heavily on the document shape, and they belong in a separate test with your data.

Also inScripting
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.