Command Palette

Search for a command to run...

[Advanced Java] Working with JSON and XML in Java: Gson, Jackson and the Defaults That Bite

Java has no JSON support in the standard library. There is no java.json, no JsonParser in java.base, nothing the JDK gives you for the format that every HTTP API on the planet speaks. What the ecosystem settled on instead is two libraries: Gson from Google and Jackson from FasterXML. Both do the same job — turn a Java object graph into a JSON document and back — and both are three lines away from working.

The interesting part is not the three lines. It is everything the mapper decides on your behalf once those three lines run: whether a null field is written or dropped, whether a key your class does not have is an error or a shrug, how a List of your type survives erasure, what happens to a LocalDate, and which of those decisions reports a problem at the point where you made the mistake. Most of them do not.

A JSON document on one side, a Java object on the other, one mapper running both directions between them

Every output and every error message below was produced by compiling and running the code on OpenJDK 21.0.6 (arm64) against Gson 2.10.1, jackson-databind / jackson-core / jackson-annotations 2.17.3, jackson-datatype-jsr310 2.17.3, and — for the XML section only — jackson-dataformat-xml 2.17.3 with woodstox-core 6.6.2 and stax2-api 4.2.2. Behaviour differs between versions, so the numbers in those coordinates matter. No timing figures appear anywhere: this article compares the two libraries on API shape, defaults, dependency weight and features, which are the things that stay true when you run it on your machine instead of mine.

What a JSON mapper actually does

Binding is a mapping between a JSON document and a Java object graph, applied one name at a time. toJson and writeValueAsString walk the object graph and emit text. fromJson and readValue walk the text and build the object graph. Those are the same mapping read in two directions, which is why a bug in one direction usually shows up as a surprise in the other.

The mapper matching JSON keys to Java fields one name at a time, with deserialize and serialize as the two directions of the same mapping

To do that, the mapper has to answer four questions for every value it meets:

QuestionWhat it decides
Which JSON key feeds which Java member?name matching, plus any renaming annotation or naming policy
What about a key with no matching member?ignore it, or fail
What about a member with no matching key?leave it at its default, or fail
How is the instance created?a no-arg constructor, a canonical record constructor, an annotated creator, or reflective allocation

Gson and Jackson answer all four. They answer two of them differently out of the box, and those two are the source of most of the confusion in this area.

Getting Gson and Jackson onto the classpath

Gson is one artifact with no runtime dependencies — its POM declares exactly one dependency, JUnit, at test scope. Jackson's databind module is three artifacts, because jackson-databind depends on jackson-core (the streaming layer) and jackson-annotations (the annotation set) at compile scope. Anything beyond core JSON — java.time support, XML, YAML, Kotlin — is a further module on top.

GsonJackson
Coordinatescom.google.code.gson:gson:2.10.1com.fasterxml.jackson.core:jackson-databind:2.17.3
Jars pulled in13
Size on disk277 KB2.2 MB total
Entry pointGson / GsonBuilderObjectMapper / JsonMapper.builder()
Streaming readercom.google.gson.stream.JsonReadercom.fasterxml.jackson.core.JsonParser
Tree nodeJsonElement / JsonObjectJsonNode
Configurationbuilder methodsfeatures, modules and annotations

With a build tool the coordinates above are all you need. Without one, the classpath is explicit:

Bash
javac -cp gson-2.10.1.jar RoundTrip.java
java  -cp gson-2.10.1.jar:. RoundTrip
 
javac -cp jackson-databind-2.17.3.jar:jackson-core-2.17.3.jar:jackson-annotations-2.17.3.jar RoundTrip.java
java  -cp jackson-databind-2.17.3.jar:jackson-core-2.17.3.jar:jackson-annotations-2.17.3.jar:. RoundTrip

Both entry points are documented as safe to share. Gson's javadoc states that a Gson instance is thread-safe; Jackson's states that an ObjectMapper is fully thread-safe as long as all configuration happens before any read or write call. Build one, configure it once, keep it in a static final field. Creating a fresh mapper per request is the single most common piece of accidental waste in this area.

The same round trip in both libraries

One model, deliberately ordinary: primitives, a String, a boolean, a List and a nested object.

Java
class Address {
    String city;
    String zip;
    Address() {}
    Address(String city, String zip) { this.city = city; this.zip = zip; }
    public String getCity() { return city; }
    public void setCity(String c) { city = c; }
    public String getZip() { return zip; }
    public void setZip(String z) { zip = z; }
    @Override public String toString() { return "Address[" + city + ", " + zip + "]"; }
}
 
public class Model {
    long id;
    String name;
    String email;
    boolean active;
    List<String> roles;
    Address address;
    // no-arg constructor, all-args constructor, getters, setters, toString
}
Java
Model u = new Model(7L, "Mai", "mai@example.com", true,
        List.of("admin", "editor"), new Address("Da Nang", "550000"));
 
Gson gson = new Gson();
String gj = gson.toJson(u);
 
ObjectMapper mapper = new ObjectMapper();
String jj = mapper.writeValueAsString(u);
 
System.out.println("equal strings? " + gj.equals(jj));
 
Model back1 = gson.fromJson(gj, Model.class);
Model back2 = mapper.readValue(jj, Model.class);
Text
gson   : {"id":7,"name":"Mai","email":"mai@example.com","active":true,"roles":["admin","editor"],"address":{"city":"Da Nang","zip":"550000"}}
jackson: {"id":7,"name":"Mai","email":"mai@example.com","active":true,"roles":["admin","editor"],"address":{"city":"Da Nang","zip":"550000"}}
equal strings? true
gson    -> Model[id=7, name=Mai, email=mai@example.com, active=true, roles=[admin, editor], address=Address[Da Nang, 550000]]
jackson -> Model[id=7, name=Mai, email=mai@example.com, active=true, roles=[admin, editor], address=Address[Da Nang, 550000]]

Byte-identical output, identical objects back. Nested objects and collections need no configuration in either library: the mapper recurses into Address and into List<String> because the declared types tell it what is there. Both entry points also read and write streams and files rather than only strings — gson.fromJson(new FileReader(f), Model.class) and mapper.readValue(new File(f), Model.class) — and you should prefer those over slurping a file into a String first.

That equality is the last thing the two libraries agree on.

Where the defaults diverge

Same class, same two calls, same input string. Only the console differs.

The same model and the same document handed to Gson and to Jackson, with the console output diverging on nulls and on an unknown key

Null fields

Gson omits them. Jackson writes them.

Java
class Account {
    public String user = "mai";
    public String nickname = null;
    public Integer age = null;
}
Text
gson default        : {"user":"mai"}
gson serializeNulls : {"user":"mai","nickname":null,"age":null}
jackson default     : {"user":"mai","nickname":null,"age":null}
jackson NON_NULL ann: {"user":"mai"}
jackson NON_NULL glb: {"user":"mai"}

Both are one call away from the other. new GsonBuilder().serializeNulls().create() makes Gson write them; @JsonInclude(JsonInclude.Include.NON_NULL) on the class, or setSerializationInclusion(JsonInclude.Include.NON_NULL) on the mapper, makes Jackson drop them. It matters more than it looks: an API consumer that distinguishes "the field is absent" from "the field is explicitly null" — a PATCH endpoint, for instance — will read the two documents above as different requests.

Unknown keys

Gson ignores a key the class does not have. Jackson refuses it.

Java
String extra = "{\"name\":\"Mai\",\"age\":30,\"nickname\":\"m\"}";
Text
gson    unknown key : User[name=Mai, age=30]
jackson unknown key FAILED: com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException
  Unrecognized field "nickname" (class Pitfalls$User), not marked as ignorable (2 known properties: "name", "age"])
jackson lenient     : User[name=Mai, age=30]

Jackson's strictness is a real feature when you own the producer, because a typo in a key becomes a build-time-style failure instead of a silently missing value. It is a real liability when you consume somebody else's API and they add a field. Turn it off globally with disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) or per class with @JsonIgnoreProperties(ignoreUnknown = true).

Malformed and surplus input

The reputation is that Gson is lenient and Jackson is strict. On the two edge cases below it is the other way round.

Text
== trailing content  {"name":"Mai","age":30} EXTRA
  gson    !! JsonSyntaxException: com.google.gson.stream.MalformedJsonException: Use JsonReader.setLenient(true) to accept malformed JSON at line 1 column 26 path $
  jackson -> User[Mai,30]
== empty string
  gson    -> null
  jackson !! MismatchedInputException: No content to map due to end-of-input

Jackson's readValue stops when the first value ends and does not look at what follows, unless you enable DeserializationFeature.FAIL_ON_TRAILING_TOKENS — with which the same input gives JsonParseException: Unrecognized token 'EXTRA'. Gson rejects the trailing token by default and returns null for an empty document. On value coercion the two agree: "age":"30" as a string is accepted into an int by both, and "age":"old" fails in both, as JsonSyntaxException wrapping a NumberFormatException and as InvalidFormatException respectively.

Renaming, ignoring and pretty printing

The JSON name and the Java name do not have to match, and some fields should never leave the process.

Java
class GsonUser {
    @SerializedName("user_name") String userName = "mai";
    transient String password = "s3cret";
    String email = "mai@example.com";
}
 
class JacksonUser {
    @JsonProperty("user_name") public String userName = "mai";
    @JsonIgnore public String password = "s3cret";
    public String email = "mai@example.com";
}
Text
gson    : {"user_name":"mai","email":"mai@example.com"}
gson pretty:
{
  "user_name": "mai",
  "email": "mai@example.com"
}
jackson : {"email":"mai@example.com","user_name":"mai"}
jackson pretty:
{
  "email" : "mai@example.com",
  "user_name" : "mai"
}
gson read back userName=lan password=s3cret
jackson read back userName=lan password=s3cret

Three details in that output are worth naming.

Gson excludes a field marked transient, reusing the keyword the language already has for "not part of the persistent state"; Jackson needs @JsonIgnore, because it looks at getters and setters rather than at field modifiers. Both left password untouched on the way back in, which is the behaviour you want for a field that should never come from the wire.

Jackson's default pretty printer writes a space before the colon — "email" : "..." — where Gson writes "email": "...". Harmless, and reliably surprising the first time a golden-file test compares the two.

And Jackson reordered the properties. That is not random: a property with an explicit @JsonProperty name is emitted after the ones whose names were inferred.

Text
no annotations : {"zeta":"1","alpha":"2","mid":"3"}
one @JsonProperty: {"zeta":"1","mid":"3","alpha_x":"2"}

If key order matters to you, pin it with @JsonPropertyOrder rather than relying on declaration order.

For a whole-model convention, use a naming policy instead of annotating every field:

Java
Gson snake = new GsonBuilder()
        .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
        .create();
 
ObjectMapper m = new ObjectMapper()
        .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
Text
gson snake_case   : {"first_name":"Mai","last_login_host":"vn-1"}
jackson snake_case: {"first_name":"Mai","last_login_host":"vn-1"}

Generics and type erasure

This is the single most asked JSON question in Java, and the answer is the same shape in both libraries: a Class object cannot carry a type argument, so you have to smuggle the full type in some other way.

Passing List.class compiles, runs, and produces something that is not what you asked for.

Java
static final String JSON = "[{\"name\":\"Mai\",\"age\":30},{\"name\":\"Lan\",\"age\":25}]";
 
List<User> gsonWrong = gson.fromJson(JSON, List.class);
List<User> jacksonWrong = mapper.readValue(JSON, List.class);
Text
gson    parsed size = 2
jackson parsed size = 2
gson    element type = com.google.gson.internal.LinkedTreeMap
jackson element type = java.util.LinkedHashMap
gson    CCE: class com.google.gson.internal.LinkedTreeMap cannot be cast to class Erasure$User (com.google.gson.internal.LinkedTreeMap and Erasure$User are in unnamed module of loader 'app')
jackson CCE: class java.util.LinkedHashMap cannot be cast to class Erasure$User (java.util.LinkedHashMap is in module java.base of loader 'bootstrap'; Erasure$User is in unnamed module of loader 'app')

Read that carefully, because the mechanism is what makes the bug confusing. Both parses succeeded. The list has the right size. What it holds is a generic map per element — a LinkedTreeMap in Gson, a LinkedHashMap in Jackson — because with the element type erased there is nothing to tell the mapper what to build. Nothing throws at that point.

The ClassCastException arrives later, at the first line of code that treats an element as a User. The compiler inserted a checkcast there, not at the parse, so the stack trace points at innocent code somewhere downstream, often in a different class and sometimes in a different thread. The reported line number has nothing to do with the mistake.

The fix is a subclassed type literal: an anonymous subclass whose superclass type argument survives into the class file, where the library can read it back by reflection.

Java
List<User> g = gson.fromJson(JSON, new TypeToken<List<User>>(){}.getType());
List<User> j = mapper.readValue(JSON, new TypeReference<List<User>>(){});
 
List<User> j2 = mapper.readValue(JSON,
        mapper.getTypeFactory().constructCollectionType(List.class, User.class));
 
User[] ga = gson.fromJson(JSON, User[].class);
User[] ja = mapper.readValue(JSON, User[].class);
 
Map<String, User> gm = gson.fromJson(MAP_JSON, new TypeToken<Map<String, User>>(){}.getType());
Map<String, User> jm = mapper.readValue(MAP_JSON, new TypeReference<Map<String, User>>(){});
Text
gson    TypeToken     -> [User[Mai,30], User[Lan,25]]  element User
jackson TypeReference -> [User[Mai,30], User[Lan,25]]  element User
jackson JavaType      -> [User[Mai,30], User[Lan,25]]
gson    User[].class  -> [User[Mai,30], User[Lan,25]]
jackson User[].class  -> [User[Mai,30], User[Lan,25]]
gson    Map           -> {a=User[Mai,30]}  value User
jackson Map           -> {a=User[Mai,30]}  value User

The trailing {} in new TypeToken<List<User>>(){} is not decoration — it is what creates the anonymous subclass. Drop it and Gson has nothing to read. Note also the fourth and fifth lines: an array type is a plain Class with no erasure problem, so User[].class works in both libraries with no type literal at all. If the shape of your data allows it, that is the least ceremonious option available.

Dates and times

The other classic. Neither library handles java.time out of the box, and both fail loudly rather than quietly — which, after the previous section, is a relief.

Java
class Event {
    public String name = "launch";
    public LocalDate day = LocalDate.of(2026, 9, 16);
    public Instant at = Instant.parse("2026-09-16T08:30:00Z");
}
Text
gson    FAILED: com.google.gson.JsonIOException
          Failed making field 'java.time.LocalDate#year' accessible; either increase its visibility or write a custom TypeAdapter for its declaring type.
jackson FAILED: com.fasterxml.jackson.databind.exc.InvalidDefinitionException
          Java 8 date/time type `java.time.LocalDate` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (through reference chain: Dates$Event["day"])

The two messages describe two different problems. Gson's reflective strategy runs straight into module encapsulation: java.time.LocalDate lives in java.base, its fields are not open to unnamed modules, and setAccessible is refused. Jackson knows the type is unsupported and names the module that supports it.

Gson's answer is a TypeAdapter per type, registered on the builder:

Java
class LocalDateAdapter extends TypeAdapter<LocalDate> {
    public void write(JsonWriter out, LocalDate v) throws IOException {
        if (v == null) out.nullValue(); else out.value(v.format(DateTimeFormatter.ISO_LOCAL_DATE));
    }
    public LocalDate read(JsonReader in) throws IOException {
        return LocalDate.parse(in.nextString(), DateTimeFormatter.ISO_LOCAL_DATE);
    }
}
 
Gson gson = new GsonBuilder()
        .registerTypeAdapter(LocalDate.class, new LocalDateAdapter())
        .registerTypeAdapter(Instant.class, new InstantAdapter())
        .create();

Jackson's answer is one extra artifact, com.fasterxml.jackson.datatype:jackson-datatype-jsr310, registered as a module. And here is the part nobody warns you about: registering the module alone is not the fix you wanted.

Text
gson adapters       : {"name":"launch","day":"2026-09-16","at":"2026-09-16T08:30:00Z"}
gson round trip     : 2026-09-16 / 2026-09-16T08:30:00Z
jackson module      : {"name":"launch","day":[2026,9,16],"at":1789547400.000000000}
jackson ISO strings : {"name":"launch","day":"2026-09-16","at":"2026-09-16T08:30:00Z"}
jackson round trip  : 2026-09-16 / 2026-09-16T08:30:00Z

With the module registered and nothing else, LocalDate is written as a three-element array and Instant as a fractional epoch second. Both round-trip correctly through Jackson, and both are useless to a JavaScript client, a log grep or a human. Disabling SerializationFeature.WRITE_DATES_AS_TIMESTAMPS switches the whole module to ISO-8601 strings, which is almost always what you meant:

Java
ObjectMapper m = JsonMapper.builder()
        .addModule(new JavaTimeModule())
        .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
        .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
        .serializationInclusion(JsonInclude.Include.NON_NULL)
        .build();

That builder is the shape worth copying into a project: one place, four decisions, every one of them made explicitly instead of inherited.

Reading without a class: the tree APIs

Sometimes there is no class to bind to — a webhook with a shifting shape, a config blob, one field out of a large response. Both libraries have a tree model for that.

Java
JsonObject o = JsonParser.parseString(j).getAsJsonObject();
o.getAsJsonObject("user").get("name").getAsString();
o.getAsJsonObject("user").getAsJsonArray("roles").get(0).getAsString();
 
JsonNode n = mapper.readTree(j);
n.at("/user/name").asText();
n.at("/user/roles/0").asText();
n.path("missing").asText("<default>");
Text
gson    name  = Mai
gson    role0 = admin
gson    absent= null
jackson name  = Mai
jackson role0 = admin
jackson absent= true
jackson path  = <default>

Jackson's at() takes a JSON Pointer, which is the most convenient thing in either API for reaching into a nested document, and it returns a missing node rather than null for a path that is not there. path() does the same one level at a time, so a chain of path().path() never throws a NullPointerException halfway down. Gson's get() returns a plain null, so you check as you go.

The tree APIs are also where an untyped-number trap lives. JSON has one numeric type; Java has six. Something has to choose.

Java
Map<String, Object> gm = gson.fromJson(j, Map.class);
Map<String, Object> jm = mapper.readValue(j, Map.class);
Text
gson    count = 42.0  (Double)
gson    ratio = 1.5  (Double)
gson    big = 9.007199254740992E15  (Double)
jackson count = 42  (Integer)
jackson ratio = 1.5  (Double)
jackson big = 9007199254740993  (Long)

Gson's default for an untyped number is Double, always. Your 42 comes back as 42.0, prints as 42.0, and concatenates into a string as 42.0. Worse, 9007199254740993 is beyond what a double represents exactly and comes back as 9.007199254740992E15 — a silently different number, which is exactly the failure mode you get with large IDs from another system. Jackson picks Integer, Long or Double by what the literal needs.

Gson has had the fix since 2.9:

Java
Gson fixed = new GsonBuilder()
        .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE)
        .create();
Text
gson LONG_OR_DOUBLE count = 42 (Long)
gson LONG_OR_DOUBLE big   = 9007199254740993 (Long)

None of this affects a typed bind: a field declared long gets a long in both libraries. It only bites when the target type is Object, Map or a tree node — which is precisely where people reach when they are being quick.

Pitfalls that show up somewhere else

Four traps, and what unites them is that three of the four hand you an object and let you discover the problem later.

Four JSON binds that fail somewhere other than where the mistake was made

A missing key leaves a default, not an error

Text
gson    missing key : User[name=Mai, age=0]
jackson missing key : User[name=Mai, age=0]

Neither library complains. age is declared int, int has no null, so the field keeps its default of 0 — and 0 is a plausible age, a plausible price and a plausible quantity. Declare optional numbers as Integer and Long so an absent key is distinguishable from a real zero, and validate after the bind rather than hoping the mapper will.

A private field with no getter

Java
class Hidden {
    private String secretNote = "kept";
    private String shown = "visible";
    public String getShown() { return shown; }
    public void setShown(String s) { shown = s; }
}
Text
gson    private field : {"secretNote":"kept","shown":"visible"}
jackson private field : {"shown":"visible"}

Gson reflects over the declared fields, so private is not a boundary and secretNote goes out on the wire. Jackson works from getters and setters by default, so a field with no accessor is simply not a property. If you are switching a service from one to the other, this is the difference most likely to leak something.

Records work; a class with no no-arg constructor is worse than it looks

Records need no special handling in either library at this version — Jackson has supported them natively since 2.12, and Gson since 2.10 when running on Java 16 or later.

Text
gson    record write  : {"x":3,"y":4}
jackson record write  : {"x":3,"y":4}
gson    record read   : Point[x=3, y=4]
jackson record read   : Point[x=3, y=4]

A plain class with only an all-args constructor is where the two part company:

Text
gson    no-arg absent : NoDefault[Mai,30]
jackson no-arg absent FAILED: com.fasterxml.jackson.databind.exc.InvalidDefinitionException
  Cannot construct instance of `Access$NoDefault` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)

Jackson refuses and tells you to add a no-arg constructor or an annotated creator. Gson succeeds — and succeeding is the problem, because it allocated the instance without running any constructor at all:

Java
class Order {
    private final String id;
    private final int quantity;
    private final List<String> lines;
 
    public Order(String id, int quantity) {
        if (quantity <= 0) throw new IllegalArgumentException("quantity must be positive");
        this.id = id;
        this.quantity = quantity;
        this.lines = new ArrayList<>();
    }
    public int lineCount() { return lines.size(); }
}
Text
gson built: Order[id=A-1, quantity=-5, lines=null]
lineCount threw: java.lang.NullPointerException: Cannot invoke "java.util.List.size()" because "this.lines" is null

The validation never ran, so quantity is -5. The field the constructor always initialises is null, because no constructor ran. The object looks fine until something calls a method on it, and then the stack trace points at lineCount, which is correct code. Every invariant your constructor enforces is off the table the moment a mapper builds the object by reflection. Give the class a no-arg constructor and validate explicitly after the bind, or use a record and let the canonical constructor do it.

Untrusted JSON and polymorphic type handling

This is the same argument that Java's own object serialization loses. A format that lets the incoming document choose which class gets instantiated hands part of your control flow to whoever wrote the document, and the classes reachable from your classpath become the attack surface. Java's built-in serialization has that property inherently, which is the reason it is discouraged for anything crossing a trust boundary.

Ordinary JSON binding does not have that property. readValue(json, User.class) builds a User and nothing else, no matter what the document says. Jackson's default typing is the feature that gives it away:

Java
ObjectMapper m = new ObjectMapper()
        .activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL);
Text
written : ["DefaultTyping$Holder",{"payload":["DefaultTyping$Thing",{"label":"x"}]}]

The Java class name is now part of the document, and on the way back in it is the document that decides what to build. That is the mechanism, and it is why every serious Jackson advisory over the years has been about default typing rather than about binding.

Keep it off unless you control both ends. Since 2.10 the unvalidated overloads are gone from the recommended path: activateDefaultTyping requires a PolymorphicTypeValidator, and a type outside the allow-list is refused.

Text
blocked : InvalidTypeIdException: Could not resolve type id 'java.util.ArrayList' as a subtype of `java.lang.Object`: Configured `PolymorphicTypeValidator` (of type `com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator`) denied resolution

When you genuinely need polymorphism, declare a closed world instead. @JsonTypeInfo with @JsonSubTypes names the permitted subtypes on the base type and writes a short discriminator rather than a class name:

Java
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind")
@JsonSubTypes({
    @JsonSubTypes.Type(value = Card.class, name = "card"),
    @JsonSubTypes.Type(value = Bank.class, name = "bank")
})
abstract class Payment { public int amount; }
Text
jackson write : {"payment":{"kind":"card","amount":100,"last4":"4242"}}
jackson read  : Card amount=100
gson write    : {"payment":{"last4":"4242","amount":100}}
gson read FAILED: com.google.gson.JsonIOException
  Abstract classes can't be instantiated! Register an InstanceCreator or a TypeAdapter for this type. Class name: Poly$Payment

The document carries "kind":"card", the mapper looks it up in a fixed list, and an unknown value is an error rather than a class load. Gson's core jar has no equivalent: it writes the runtime subclass's fields with no discriminator at all, and then cannot read its own output back. Polymorphism in Gson means a hand-written TypeAdapterFactory, or the RuntimeTypeAdapterFactory that lives in the separate gson-extras project rather than in gson-2.10.1.jar.

Streaming a document too big to bind

Whole-document binding builds the entire object graph in memory before you touch any of it. For a response of a few hundred kilobytes that is the right trade. For an export file it is not, and the failure is abrupt.

Both libraries expose a pull parser under the binding layer, and both let you bind one record at a time from inside it — which gives you typed objects without ever holding more than one:

Java
static long gsonStream(String path) throws IOException {
    long high = 0;
    Gson gson = new Gson();
    try (JsonReader r = new JsonReader(new BufferedReader(new FileReader(path)))) {
        r.beginArray();
        while (r.hasNext()) {
            Row row = gson.fromJson(r, Row.class);   // one record at a time
            if (row.score > 95) high++;
        }
        r.endArray();
    }
    return high;
}
 
static long jacksonStream(String path) throws IOException {
    long high = 0;
    JsonFactory f = new ObjectMapper().getFactory();
    try (JsonParser p = f.createParser(new File(path))) {
        if (p.nextToken() != JsonToken.START_ARRAY) throw new IOException("expected an array");
        while (p.nextToken() == JsonToken.START_OBJECT) {
            Row row = p.readValueAs(Row.class);      // one record at a time
            if (row.score > 95) high++;
        }
    }
    return high;
}

Against a 43.6 MB file of one million records, on the same JVM and the same data:

Text
$ java -Xmx16m ... Streaming gson big.json
gson: rows with score > 95 = 40000
 
$ java -Xmx16m ... Streaming jackson big.json
jackson: rows with score > 95 = 40000
 
$ java -Xmx64m ... Streaming bind big.json
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at com.fasterxml.jackson.core.util.TextBuffer.setCurrentAndReturn(TextBuffer.java:925)
	at com.fasterxml.jackson.core.json.UTF8StreamJsonParser._finishAndReturnString(UTF8StreamJsonParser.java:2512)
	at com.fasterxml.jackson.core.json.UTF8StreamJsonParser.getText(UTF8StreamJsonParser.java:294)

Streaming finished in a 16 MB heap. The equivalent readValue into a List of the same type died at 64 MB and needed 96 MB to complete. The ratio is not the point — it depends entirely on the document — but the shape is: streaming cost is bounded by one record, binding cost is bounded by the whole file. Reach for a pull parser when the document is large or unbounded, when you only need a few fields from each record, or when records can be processed and discarded as they arrive. Bind the whole thing when it is small, when you need random access across it, or when the code is clearer for it — which is most of the time.

XML on a modern JDK

JAXB — javax.xml.bind, the annotation-driven XML binding that used to ship with the JDK — was removed in Java 11 along with the rest of the Java EE modules. On OpenJDK 21 it is simply not there:

Text
$ javap javax.xml.bind.JAXBContext
Error: class not found: javax.xml.bind.JAXBContext
$ javap jakarta.xml.bind.JAXBContext
Error: class not found: jakarta.xml.bind.JAXBContext

What remains is the java.xml module, which is very much present and needs no dependency at all:

Text
$ java --list-modules | grep xml
java.xml@21.0.6
java.xml.crypto@21.0.6
jdk.xml.dom@21.0.6

That gives you DOM, SAX, StAX, XPath and XSLT. For the size of XML most Java code actually meets — a config file, a legacy SOAP payload, a feed — DOM plus XPath is enough, and StAX covers the streaming case the same way JsonReader does for JSON:

Java
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
Document doc = dbf.newDocumentBuilder().parse(f);
 
XPath xp = XPathFactory.newInstance().newXPath();
System.out.println("host = " + xp.evaluate("/server/host/text()", doc));
 
NodeList routes = (NodeList) xp.evaluate("/server/routes/route", doc, XPathConstants.NODESET);
for (int i = 0; i < routes.getLength(); i++) {
    Element e = (Element) routes.item(i);
    System.out.println("route " + e.getAttribute("method") + " " + e.getAttribute("path"));
}
 
XMLInputFactory xif = XMLInputFactory.newInstance();
xif.setProperty(XMLInputFactory.SUPPORT_DTD, false);
xif.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);

Against this document:

XML
<?xml version="1.0" encoding="UTF-8"?>
<server>
  <host>api.example.com</host>
  <port>8443</port>
  <routes>
    <route path="/users" method="GET"/>
    <route path="/orders" method="POST"/>
  </routes>
</server>
Text
host = api.example.com
port = 8443
route GET /users
route POST /orders
StAX counted 2 route elements

Those three factory settings are not optional decoration. An XML parser will, by default, resolve entity declarations in the document, and that is the XXE class of vulnerability — a document that reads local files or opens network connections while being parsed. JSON has no equivalent feature and therefore no equivalent problem. Turn DTDs and external entities off on every parser that touches input you did not write.

If you want XML binding rather than XML parsing, Jackson has it, as one more module over the same ObjectMapper machinery:

Java
XmlMapper xm = new XmlMapper();
Server s = xm.readValue(xml, Server.class);
Text
bound  : api.example.com:8443 [GET /users, POST /orders]
re-xml : <Server><host>api.example.com</host><port>8443</port><routes><routes><path>/users</path><method>GET</method></routes><routes><path>/orders</path><method>POST</method></routes></routes></Server>
as json: {"host":"api.example.com","port":8443,"routes":[{"path":"/users","method":"GET"},{"path":"/orders","method":"POST"}]}

Reading worked with no annotations. Writing did not round-trip cleanly, and the output shows why XML binding is harder than JSON binding: the root element became Server rather than server, and the list produced routes nested inside routes because XML has no native array — a wrapper element and an item element are two different names, and Jackson needs @JacksonXmlRootElement, @JacksonXmlElementWrapper and @JacksonXmlProperty to be told which is which. Attributes versus child elements is a second decision JSON never has to make, and mixed content is a third.

That extra work is the honest summary of XML on the JVM today. It is not deprecated, the JDK support is solid and free, and you will meet it in Maven POMs, Spring XML configuration, SOAP endpoints and old feeds. But new services do not choose it: JSON is what HTTP APIs speak, what browsers parse natively, and what has one obvious mapping to an object graph instead of three ambiguous ones.

FAQ

Should I use Gson or Jackson?

Jackson if you are inside a framework that already ships it — Spring Boot, Quarkus and Micronaut all default to it — or if you need annotations, modules, XML, YAML, or strict handling of unknown fields. Gson if you want one small jar with no transitive dependencies and no annotations on your model, which is why it is the common choice on Android and in small tools. On features Jackson is the larger library by a wide margin; on getting a plain object in and out, they are equivalent.

Why did my list of objects come back as a list of maps?

Because you passed List.class and the element type was erased. Both libraries fill the list with generic maps — LinkedTreeMap for Gson, LinkedHashMap for Jackson — and neither throws at that point. Use new TypeToken<List<User>>(){}.getType() in Gson or new TypeReference<List<User>>(){} in Jackson, keeping the trailing braces, or bind to User[].class, which needs no type literal at all.

Why did my LocalDate become an array of three numbers?

Because you registered JavaTimeModule but left SerializationFeature.WRITE_DATES_AS_TIMESTAMPS enabled, which is Jackson's default. That is what produced "day":[2026,9,16] and "at":1789547400.000000000 above. Disable that feature and the same module writes ISO-8601 strings.

Are Gson and ObjectMapper thread-safe?

Both are documented as safe to share: Gson's javadoc says a Gson instance is thread-safe, Jackson's says an ObjectMapper is fully thread-safe provided all configuration happens before the first read or write. Configure once, store in a static final field, reuse everywhere. Reconfiguring a mapper that is already in use is the part that is not safe.

How do I stop Jackson throwing on an unknown key?

disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) on the mapper turns it off everywhere; @JsonIgnoreProperties(ignoreUnknown = true) turns it off for one class. Prefer the annotation when only some of your models consume third-party payloads, since the global switch also hides typos in documents you produce yourself.

Why did Gson turn my integer into a double?

Because you deserialized into Object, Map or a tree node, and Gson's default number strategy for an untyped value is Double. 42 becomes 42.0, and an ID larger than about nine quadrillion loses precision silently. Either bind to a typed field, where the declared type decides, or set setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) on the builder.

Can Jackson and Gson deserialize into a record?

Yes, both, at the versions here. Jackson has supported records since 2.12 and Gson since 2.10 on Java 16 or later; both write {"x":3,"y":4} for a two-component record and read it straight back through the canonical constructor. That also makes records the cleanest answer to the constructor-bypass problem, because the canonical constructor genuinely runs and any validation in it applies.

Is it safe to deserialize untrusted JSON?

Binding to a concrete type you named is safe in the sense that matters: the document supplies values, your code supplies the class. What is not safe is any configuration that lets the document choose the type — Jackson's default typing being the one to know about. Leave it off, use @JsonTypeInfo with an explicit @JsonSubTypes list when you need polymorphism, and validate the resulting object rather than trusting that a successful parse means sensible data.

Conclusion

JSON binding in Java is a mapping between a document and an object graph, and both libraries implement it well enough that the round trip in this article produced byte-identical output. What separates them is the defaults. Gson drops null fields, ignores unknown keys, reads private fields directly, turns untyped numbers into doubles, and will build an object without running its constructor. Jackson writes nulls, throws on unknown keys, works from getters and setters, picks a sensible numeric type, and refuses to build an object it has no creator for. Neither set is wrong, and neither is guessable — which is why the first thing worth writing in any project is a single configured mapper in one place, with every one of those decisions made on purpose.

The traps worth carrying away are the ones that do not fail where you made the mistake. A generic type without a TypeToken or TypeReference parses successfully and hands you maps, and the ClassCastException lands in unrelated code later. A missing key leaves 0 in an int and nothing complains. A constructor that enforces an invariant does not run when Gson allocates the instance, so the object is valid-looking and broken. java.time fails loudly, which is the friendliest behaviour in this whole article, but registering the module without disabling timestamp output produces [2026,9,16] and calls it a date. And when the document is large enough, the right answer stops being a bind at all: streaming finished the same job in a 16 MB heap that whole-document binding could not do in 64 MB.

That is data on the wire. The next article turns to data at rest: article 26 covers JDBC — connections, Statement versus PreparedStatement, result sets, transactions and the SQL injection that string concatenation invites.

Related Posts

[Advanced Java] Mocking with Mockito: Stubbing, Verification and When Not to Mock

Mockito 5.14.2 on OpenJDK 21: what a test double is and why a hand-written fake often beats a library, what an unstubbed mock returns, stubbing with when and thenReturn, argument matchers and the real InvalidUseOfMatchersException, verify with times, never, InOrder and ArgumentCaptor, MockitoExtension with Mock and InjectMocks, the spy trap, mocking final classes and static methods with the inline mock maker, and the over-mocking failure mode where a test that mocks everything tests nothing.

[Advanced Java] SOLID Principles in Java: Five Rules and When to Break Them

The five SOLID principles in Java on OpenJDK 21, each with a before and after that compiles and runs: a class split by its reasons to change, a growing switch replaced by an interface, a subclass that breaks its caller with no warning, an UnsupportedOperationException the compiler could have prevented, a class that cannot run without a file, and where each principle stops paying for itself.

[Advanced Java] Common Design Patterns in Java: Singleton, Factory, Builder, Observer and Strategy

Five design patterns in Java on OpenJDK 21, each one demonstrated inside the JDK itself and each one shown where it does damage: singleton initialisation and the race an unsynchronised null check loses, Integer.valueOf and its cache, a staged builder the compiler checks, a listener that leaks and a listener that stops the broadcast, and Comparator as the strategy type you have already been using.

[Advanced Java] Threads in Java: Thread, Runnable and Virtual Threads

Threads in Java on OpenJDK 21: what a thread is, its own stack against the shared heap, creating one with Thread, Runnable and a lambda, start versus run, join, daemon threads, names and priorities, non-deterministic output, virtual threads with Thread.ofVirtual, and cooperative interruption.