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.

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
{"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
| Behavior | Go | Java (Jackson) | Rust (serde_json) | C# (STJ) |
|---|---|---|---|---|
| Unknown key | ignored | exception | ignored | ignored |
| Switch | DisallowUnknownFields | FAIL_ON_UNKNOWN_PROPERTIES off | deny_unknown_fields | UnmappedMemberHandling.Disallow |
| Key case | insensitive | sensitive | sensitive | sensitive (Web preset: insensitive) |
| Missing field | zero value | not run | error unless Option or default | default; required makes it an error |
JSON null for an int | no-op (documented) | not run | not run | JsonException |
| Big id in typed field | exact (int64) | exact (long) | exact (i64) | exact (long) |
| Big id in dynamic value | float (any); exact with UseNumber | exact (JsonNode long) | exact (Value i64) | double read loses it |
| Duplicate key | last | last (error with strict flag) | struct: error; Value: last | last (JsonDocument) |
| Depth limit | not run | 1,000 | 128 | 64 |
| Trailing tokens | error | accepted by default | error | error |
| Behavior | PHP | Ruby (json 2.9.1) | Python | Node |
|---|---|---|---|---|
| Unknown key | no model (array or object) | no model (Hash) | no model (dict) | no model (object) |
| Null vs missing | null vs unset (??) | nil vs absent key | None vs absent key | null vs undefined |
| Big id | exact up to 64-bit int | exact, unbounded | exact, unbounded | rounded to 9007199254740992 |
| Duplicate key | last | last | last | last |
| Depth limit | 512 | 100 | none on parse (RecursionError at 100,000) | none (1,000,000 parsed) |
| Trailing comma | error | error | error | error |
| Comments | error | accepted | error | error |
NaN | error | error (allow_nan opts in) | accepted | error |
| Trailing tokens | error | error | error | error |
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:
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 errorThe 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
nilfor 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>isNonefor both. A nestedOption<Option<T>>with a custom deserializer givesNone,Some(None)andSome(Some(v)).Java: a tree (
JsonNode) tells them apart (getreturns Java null for missing, aNullNodefor JSON null;pathreturns aMissingNode).C#:
intwith a JSONnullthrowsJsonException; a missingintis 0. Useint?orrequired.PHP, Ruby, Python and Node: the dynamic structures keep both: PHP
array_key_existsagainstisset, Rubykey?, Pythonin, JavaScriptinorhasOwnProperty.
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:
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 9007199254740992Typed 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:
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:
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:
| Writing | Result |
|---|---|
Java Instant with the Java time module | 1767323045.000000000 (a number) until WRITE_DATES_AS_TIMESTAMPS is disabled |
C# DateTimeOffset | "2026-01-02T03:04:05+00:00" |
Rust f64 12.0 | 12.0 |
| PHP float 1.0 | 1 unless JSON_PRESERVE_ZERO_FRACTION |
| Go struct | keys 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-8 | false and an error message, unless a flag substitutes |
Ruby / PHP / Python NaN | Ruby 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:
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.
Keep a folder of sample documents, including the awkward ones from this guide: a 16 digit id, a null, a duplicate key, a
STATUSin the wrong case, a document nested 200 deep.Run the same folder through each service's reader in CI and assert the result. A short test per service is enough.
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
Go: parsing JSON in Go with struct tags and handling unknown JSON fields in Go.
Ruby: reading JSON in Ruby.
Python and Node are covered in the earlier cluster: handling JSON in Python beyond json.loads and JSON in JavaScript and Node done carefully.
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.


