Three libraries dominate JSON handling on the JVM, and the choice is usually made by inertia rather than analysis. Jackson ships with Spring Boot, so most services never evaluate anything else. That default is defensible, but it is worth knowing what you are getting and where the alternatives genuinely differ.
The short version: Jackson if you are in Spring or need the module ecosystem, Gson if you want a small dependency with no configuration surface, Moshi if you are writing Kotlin. The reasoning below matters more than the summary, because one of those three behaves very differently in Java than its reputation suggests.
Architecture, and what follows from it
All three are built on a streaming core with a binding layer on top. The difference is what the binding layer does at runtime.
| Jackson | Gson | Moshi | |
|---|---|---|---|
| Binding strategy | Reflection, with cached serialisers per type | Reflection, with cached type adapters | Adapters; generated at compile time for Kotlin, reflective for Java |
| Streaming API | JsonParser / JsonGenerator | JsonReader / JsonWriter | JsonReader / JsonWriter, over Okio |
| Unknown fields | Throws by default | Ignores | Ignores |
| Transitive deps | jackson-core, jackson-annotations | None | Okio |
| Spring Boot | Auto-configured default | Auto-configured, opt in | No auto-configuration |
The Moshi caveat that decides most Java evaluations
Moshi's headline advantage is compile-time generated adapters: no reflection,
no kotlin-reflect, less startup cost. That advantage
does not exist in Java. The
@JsonClass(generateAdapter = true) annotation is processed by a Kotlin
symbol processor, and it only runs against Kotlin classes. Point Moshi at a plain
Java POJO and you get ClassJsonAdapter, which is reflective, which is
the same broad approach Gson takes.
So the honest framing is: in a Kotlin codebase Moshi is a genuinely different proposition, and in a Java codebase it is a smaller, stricter Gson that costs you an Okio dependency and all of your Spring integration. That is not an argument against it, but it is an argument against choosing it for benchmark numbers that were measured in Kotlin.
Parsing the same payload
Given a model of the shape most order APIs return:
public class Order {
public String orderId;
public BigDecimal total;
public Customer customer;
public List<LineItem> items;
}
Jackson
// com.fasterxml.jackson.databind.ObjectMapper
ObjectMapper mapper = new ObjectMapper();
Order order = mapper.readValue(json, Order.class);
String out = mapper.writeValueAsString(order);
// Throws JsonProcessingException
Gson
// com.google.gson.Gson
Gson gson = new Gson();
Order order = gson.fromJson(json, Order.class);
String out = gson.toJson(order);
// Throws JsonSyntaxException (unchecked)
Moshi
// com.squareup.moshi.Moshi
Moshi moshi = new Moshi.Builder().build();
JsonAdapter<Order> a = moshi.adapter(Order.class);
Order order = a.fromJson(json);
String out = a.toJson(order);
// Throws IOException
Note the exception contracts, because they leak into your error handling. Jackson
throws a checked JsonProcessingException. Gson throws an unchecked
JsonSyntaxException, which is easy to forget to catch at a service
boundary. Moshi throws IOException, because it reads through Okio
rather than from a String.
Unknown properties: the one default that will bite you
A provider adds a field to their response. Two of these libraries keep working
and one throws UnrecognizedPropertyException in production. This is the single most consequential
behavioural difference between them.
Jackson: strict by default, except in Spring Boot
A bare ObjectMapper has
FAIL_ON_UNKNOWN_PROPERTIES enabled, so an unexpected key is a hard
failure. Spring Boot's auto-configuration disables it, and has since Boot 1.2 - which
is why the same DTO behaves differently in a unit test that news up its own mapper
than it does inside the running application. If you have ever had a parsing test fail
only in isolation, this is usually why.
// Global: turn the failure off for every type
ObjectMapper mapper = JsonMapper.builder()
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build();
// Per type: keep strictness everywhere else
@JsonIgnoreProperties(ignoreUnknown = true)
public class Order { /* ... */ }
// Capture the extras instead of discarding them
public class Order {
public String orderId;
private final Map<String, Object> unknown = new LinkedHashMap<>();
@JsonAnySetter
void put(String key, Object value) {
unknown.put(key, value);
}
}
Gson: silent, always
// Nothing to configure. Gson skips fields it cannot map.
Gson gson = new Gson();
Order order = gson.fromJson(json, Order.class);
// To detect extras you must read the tree yourself
JsonObject tree = JsonParser.parseString(json).getAsJsonObject();
Set<String> known = Set.of("orderId", "total", "customer", "items");
tree.keySet().stream()
.filter(k -> !known.contains(k))
.forEach(k -> log.warn("unmapped field: {}", k));
Moshi: silent, but with an opt-in switch
// Also silent by default: the reflective adapter calls
// JsonReader.skipValue() on names it does not recognise.
Moshi moshi = new Moshi.Builder().build();
JsonAdapter<Order> adapter = moshi.adapter(Order.class);
// failOnUnknown() opts one adapter back into strictness
JsonAdapter<Order> strict = adapter.failOnUnknown();
strict.fromJson(json); // throws JsonDataException on an extra key
Moshi is the only one of the three that lets you flip strictness on a single adapter rather than globally or per class, which is useful when exactly one upstream contract is worth being pedantic about.
Deeply nested structures
Binding is recursive in all three, so nesting depth translates into stack depth.
Jackson is the only one that bounds this for you: recent 2.x releases enforce a
maximum nesting depth through StreamReadConstraints, which turns a
potential StackOverflowError on hostile input into a catchable parse
exception. The exact ceilings have moved between releases, so print them rather than
trusting any article, including this one.
// Jackson caps nesting depth to bound stack use on hostile input.
// The defaults differ between 2.x releases, so read them rather than assume:
System.out.println(StreamReadConstraints.defaults());
JsonFactory factory = JsonFactory.builder()
.streamReadConstraints(StreamReadConstraints.builder()
.maxNestingDepth(2_000)
.build())
.build();
Measured against Jackson 2.17.2, those defaults are a maximum nesting depth
of 1000, a maximum string length of 20000000, and a
maximum number length of 1000. Treat the numbers as version-specific
and the mechanism as the durable part.
Gson and Moshi have no equivalent depth guard. If you accept JSON from anywhere you do not control, that asymmetry is a security consideration and not just a robustness one.
Spring Boot integration
This is where the three genuinely diverge, and where the version you are on matters more than the library you prefer.
Spring Boot 4 moved to Jackson 3 as the auto-configured default, and Jackson 2 support is deprecated and slated for removal in a later 4.x release. It also replaced the single preferred-mapper property with context-specific keys.
# Spring Boot 3.x - single property, values: jackson | gson | jsonb
spring.mvc.converters.preferred-json-mapper=gson
# Spring Boot 4.x - Jackson 3 is the default; the keys are context specific
spring.http.converters.preferred-json-mapper=jackson2 # Spring MVC, imperative clients
spring.http.codecs.preferred-json-mapper=jackson2 # WebFlux, reactive clients
# Jackson tuning is unchanged across both
spring.jackson.deserialization.fail-on-unknown-properties=true
Gson still has first-class auto-configuration: put it on the classpath and you
get a Gson bean, spring.gson.* properties, and
GsonBuilderCustomizer beans for anything those do not cover.
Moshi has no auto-configuration at all. Using it for HTTP message conversion means writing and registering the converter yourself:
public class MoshiHttpMessageConverter extends AbstractHttpMessageConverter<Object> {
private final Moshi moshi = new Moshi.Builder().build();
public MoshiHttpMessageConverter() {
super(MediaType.APPLICATION_JSON);
}
@Override
protected boolean supports(Class<?> clazz) {
return true;
}
@Override
protected Object readInternal(Class<?> clazz, HttpInputMessage message)
throws IOException {
JsonAdapter<?> adapter = moshi.adapter(clazz);
try (BufferedSource source = Okio.buffer(Okio.source(message.getBody()))) {
return adapter.fromJson(source);
}
}
@Override
protected void writeInternal(Object body, HttpOutputMessage message)
throws IOException {
@SuppressWarnings("unchecked")
JsonAdapter<Object> adapter =
(JsonAdapter<Object>) moshi.adapter(body.getClass());
try (BufferedSink sink = Okio.buffer(Okio.sink(message.getBody()))) {
adapter.toJson(sink, body);
}
}
}
That is roughly forty lines you now own, plus the Okio dependency, in exchange for a library whose main advantage does not apply to Java. Worth doing if Moshi is already in the codebase for other reasons; hard to justify otherwise.
Benchmark on your payload, not on someone else's
Published throughput comparisons between these libraries are close enough, and volatile enough across versions, that they rarely survive contact with a real payload. Field count, nesting depth, string-to-number ratio, array length and whether you reuse the mapper all move the result more than the library choice does. Measure it:
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
@State(Scope.Benchmark)
@Fork(value = 2, jvmArgs = {"-Xms2G", "-Xmx2G"})
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 10, time = 1)
public class JsonLibraryBenchmark {
private String payload;
private ObjectMapper jackson;
private Gson gson;
private JsonAdapter<Order> moshi;
@Setup
public void setUp() throws IOException {
// Use a payload from your own API, not a synthetic one.
payload = Files.readString(Path.of("src/jmh/resources/order.json"));
jackson = new ObjectMapper();
gson = new Gson();
moshi = new Moshi.Builder().build().adapter(Order.class);
}
@Benchmark
public Order jackson() throws IOException {
return jackson.readValue(payload, Order.class);
}
@Benchmark
public Order gson() {
return gson.fromJson(payload, Order.class);
}
@Benchmark
public Order moshi() throws IOException {
return moshi.fromJson(payload);
}
}
Two things that will dominate whatever you find. First, reuse the
mapper: ObjectMapper, Gson and
JsonAdapter are all thread-safe and all cache per-type metadata, so
constructing one per request discards that cache and is usually a far larger
regression than any difference between the three. Second, measure allocation with
-prof gc, not just throughput - on a busy service, allocation pressure
and the GC time it causes matter more than raw parse speed.
Map the payload before you write the DTO
Most of the time lost in this work is not parsing, it is discovering what the response actually contains - which fields are nested where, which are optional, which arrive as strings when you assumed numbers. Scrolling a pretty-printed response in a terminal is a slow way to answer that.
Map an API response before writing the DTO
Paste a sample response into JSON Keyper to get every key path with its value type, or generate a typed interface to check your DTO field names against.
Open JSON KeyperThe typed key-path output answers the question you actually have when writing a
DTO: is total a number or a string, and is giftMessage
present on every element of the array or only some. The
interface generator is a quick cross-check on
field names and optionality before you hand-write the Java equivalent.
Where this approach stops working
Sample-sized payloads only. JSON Keyper buffers the whole
document in a browser tab, so it suits a representative response of a few
megabytes - not a bulk export. Past roughly ten megabytes it gets slow, and in
the hundreds of megabytes the tab will exhaust its heap and die. The same
ceiling applies to ObjectMapper.readValue,
Gson.fromJson and JsonAdapter.fromJson: all three
materialise the entire object graph.
At that size, stop binding and start streaming. Use the
streaming parser directly so one record is live at a time, or do the inspection
outside the JVM with jq --stream. Note the flag: plain
jq parses the whole document into memory first, which measured
about 1.1 GB on a 120 MB input. See
processing large JSON files
for the measurements.
// Constant memory: one LineItem is live at a time, never the whole array.
JsonFactory factory = new JsonFactory();
ObjectMapper mapper = new ObjectMapper();
try (JsonParser parser = factory.createParser(new File("orders-800mb.json"))) {
while (parser.nextToken() != JsonToken.END_ARRAY) {
if (parser.currentToken() == JsonToken.START_OBJECT) {
LineItem item = mapper.readValue(parser, LineItem.class);
process(item);
}
}
}
For one-off inspection of a file that size, do not write Java at all:
# --stream is required here. Plain `jq -c '.orders[]'` builds the whole
# document first: roughly 1.1 GB resident for a 120 MB input.
jq -cn --stream \
'fromstream(2 | truncate_stream(inputs | select(.[0][0] == "orders")))' \
orders-800mb.json > lines.ndjson
# Key-path inventory in constant memory (about 12 MB, regardless of size)
jq -rn --stream '
inputs | select(length == 2) | .[0]
| reduce .[] as $p ("";
if ($p | type) == "number" then . + "[]"
else (if . == "" then $p else . + "." + $p end) end)
' orders-800mb.json | sort -u
The practical rule: use a browser tool or a databinding call to understand a
sample, and a streaming parser or jq to process the
corpus. Reaching for the wrong one is the most common cause of an
OutOfMemoryError in an otherwise correct ingestion job.
Choosing
| Situation | Pick | Why |
|---|---|---|
| Spring Boot service | Jackson | Already auto-configured; the module ecosystem covers JSR-310, Kotlin, Guava and XML with no glue code. |
| Library or SDK you ship to others | Gson | No transitive dependencies, so you cannot create a version conflict in someone else's build. |
| Kotlin codebase | Moshi | Codegen adapters apply here; null-safety is respected rather than bypassed by reflection. |
| Untrusted input | Jackson | The only one with a built-in nesting-depth bound. |
| Android | Moshi or Gson | Smaller method count; Moshi avoids reflection when the models are Kotlin. |
| Files in the hundreds of MB | Streaming API, or jq | Databinding materialises the whole graph. The library choice is irrelevant at that size. |
If you are already on Jackson inside Spring and considering a switch, the honest answer is that parse throughput is almost never the bottleneck worth chasing. Connection pooling, N+1 queries and serialisation of responses you did not need to send will all cost more. Benchmark before migrating anything.