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.
![]()
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.

To do that, the mapper has to answer four questions for every value it meets:
| Question | What 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.
| Gson | Jackson | |
|---|---|---|
| Coordinates | com.google.code.gson:gson:2.10.1 | com.fasterxml.jackson.core:jackson-databind:2.17.3 |
| Jars pulled in | 1 | 3 |
| Size on disk | 277 KB | 2.2 MB total |
| Entry point | Gson / GsonBuilder | ObjectMapper / JsonMapper.builder() |
| Streaming reader | com.google.gson.stream.JsonReader | com.fasterxml.jackson.core.JsonParser |
| Tree node | JsonElement / JsonObject | JsonNode |
| Configuration | builder methods | features, modules and annotations |
With a build tool the coordinates above are all you need. Without one, the classpath is explicit:
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:. RoundTripBoth 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.
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
}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);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.

Null fields
Gson omits them. Jackson writes them.
class Account {
public String user = "mai";
public String nickname = null;
public Integer age = null;
}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.
String extra = "{\"name\":\"Mai\",\"age\":30,\"nickname\":\"m\"}";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.
== 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-inputJackson'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.
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";
}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=s3cretThree 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.
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:
Gson snake = new GsonBuilder()
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.create();
ObjectMapper m = new ObjectMapper()
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);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.
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);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.
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>>(){});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 UserThe 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.
class Event {
public String name = "launch";
public LocalDate day = LocalDate.of(2026, 9, 16);
public Instant at = Instant.parse("2026-09-16T08:30:00Z");
}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:
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.
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:00ZWith 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:
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.
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>");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.
Map<String, Object> gm = gson.fromJson(j, Map.class);
Map<String, Object> jm = mapper.readValue(j, Map.class);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:
Gson fixed = new GsonBuilder()
.setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE)
.create();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.

A missing key leaves a default, not an error
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
class Hidden {
private String secretNote = "kept";
private String shown = "visible";
public String getShown() { return shown; }
public void setShown(String s) { shown = s; }
}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.
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:
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:
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(); }
}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 nullThe 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:
ObjectMapper m = new ObjectMapper()
.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL);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.
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 resolutionWhen 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:
@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; }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$PaymentThe 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:
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:
$ 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:
$ 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.JAXBContextWhat remains is the java.xml module, which is very much present and needs no dependency at all:
$ java --list-modules | grep xml
java.xml@21.0.6
java.xml.crypto@21.0.6
jdk.xml.dom@21.0.6That 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:
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 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>host = api.example.com
port = 8443
route GET /users
route POST /orders
StAX counted 2 route elementsThose 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:
XmlMapper xm = new XmlMapper();
Server s = xm.readValue(xml, Server.class);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.