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.

JacksonGsonMoshi
Binding strategyReflection, with cached serialisers per typeReflection, with cached type adaptersAdapters; generated at compile time for Kotlin, reflective for Java
Streaming APIJsonParser / JsonGeneratorJsonReader / JsonWriterJsonReader / JsonWriter, over Okio
Unknown fieldsThrows by defaultIgnoresIgnores
Transitive depsjackson-core, jackson-annotationsNoneOkio
Spring BootAuto-configured defaultAuto-configured, opt inNo 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 Keyper

The 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

SituationPickWhy
Spring Boot serviceJacksonAlready auto-configured; the module ecosystem covers JSR-310, Kotlin, Guava and XML with no glue code.
Library or SDK you ship to othersGsonNo transitive dependencies, so you cannot create a version conflict in someone else's build.
Kotlin codebaseMoshiCodegen adapters apply here; null-safety is respected rather than bypassed by reflection.
Untrusted inputJacksonThe only one with a built-in nesting-depth bound.
AndroidMoshi or GsonSmaller method count; Moshi avoids reflection when the models are Kotlin.
Files in the hundreds of MBStreaming API, or jqDatabinding 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.

Further reading