Jackson Custom Serializers & Deserializers in Java
When annotations cannot describe the JSON you need, Jackson lets you write the conversion yourself. A serializer writes a value as JSON tokens and a deserializer reads tokens back into a value. This page explains where custom code plugs into a mapper, then runs each option, from a full serializer to converters, contextual serializers and modifiers, with the traps and the cost of each.
Contents
Where custom serialization plugs in A serializer and deserializer for a value type Reading tokens directly Per-field formats with @JsonDeserialize Converters: custom mapping without token code Contextual serializers: one serializer, per-field settings Modifiers, and the recursion trap What a custom deserializer costs Which tool to use
Where custom serialization plugs in
To write a value, Jackson asks its SerializerProviderJsonSerializerserialize(value, generator, provider)JsonGeneratorJsonDeserializerJsonParser
A custom serializer replaces the one Jackson would build from the class's properties. There are several places to plug one in, and they differ in scope: everywhere a type appears, one field, or every bean in the application.
| Mechanism | Scope | Best for |
|---|---|---|
StdSerializerStdDeserializerSimpleModule | Every use of the type on that mapper | Value types with their own JSON form, such as money |
@JsonSerialize(using = ...)@JsonDeserialize(using = ...) | One field, or one class when placed on the class | Legacy formats in selected fields |
contentUsingkeyUsing | Elements or map keys of one field | Lists and maps of specially formatted values |
StdConverterconverter = ... | One field or type | Mapping to a type Jackson already handles, without token code |
ContextualSerializer | Each property, configured by its annotations | One serializer with per-field options |
BeanSerializerModifier | Every bean type | Cross-cutting rules, such as dropping secret properties |
The examples use Jackson 2.21 on JDK 21. In Jackson 3 the same classes exist under tools.jacksonJsonSerializerValueSerializerSerializerProviderSerializationContext
A serializer and deserializer for a value type
A MoneyStdSerializerJsonGeneratorStdDeserializerSimpleModuleMoney19.901E+3BigDecimal
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.Currency;
import java.util.List;
public class MoneySerializer {
// A value type we want to appear in JSON as {"amount":"19.90","currency":"EUR"}
record Money(BigDecimal amount, Currency currency) {
}
static class MoneyJsonSerializer extends StdSerializer<Money> {
MoneyJsonSerializer() {
super(Money.class);
}
@Override
public void serialize(Money m, JsonGenerator gen, SerializerProvider provider)
throws IOException {
gen.writeStartObject();
gen.writeStringField("amount", m.amount().toPlainString()); // exact, no 1E+3
gen.writeStringField("currency", m.currency().getCurrencyCode());
gen.writeEndObject();
}
}
static class MoneyJsonDeserializer extends StdDeserializer<Money> {
MoneyJsonDeserializer() {
super(Money.class);
}
@Override
public Money deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
JsonNode node = p.readValueAsTree();
JsonNode amount = node.get("amount");
if (amount == null || node.get("currency") == null) {
return (Money) ctxt.handleWeirdStringValue(Money.class, node.toString(),
"expected amount and currency");
}
return new Money(new BigDecimal(amount.asText()),
Currency.getInstance(node.get("currency").asText()));
}
}
record Invoice(String number, List<Money> lines) {
}
public static void main(String[] args) throws Exception {
ObjectMapper mapper = JsonMapper.builder()
.addModule(new SimpleModule("money")
.addSerializer(Money.class, new MoneyJsonSerializer())
.addDeserializer(Money.class, new MoneyJsonDeserializer()))
.build();
Invoice inv = new Invoice("INV-1", List.of(
new Money(new BigDecimal("19.90"), Currency.getInstance("EUR")),
new Money(new BigDecimal("1E+3"), Currency.getInstance("EUR"))));
String json = mapper.writeValueAsString(inv);
System.out.println("written: " + json);
Invoice back = mapper.readValue(json, Invoice.class);
BigDecimal original = inv.lines().get(1).amount();
BigDecimal restored = back.lines().get(1).amount();
System.out.println("1E+3 read back as " + restored + ": equals " + original.equals(restored)
+ ", compareTo " + original.compareTo(restored));
// Default handling for comparison: BigDecimal as a JSON number, Currency as a string
System.out.println("default: " + new ObjectMapper().writeValueAsString(inv));
try {
mapper.readValue("{\"number\":\"INV-2\",\"lines\":[{\"amount\":\"5\"}]}",
Invoice.class);
} catch (Exception e) {
System.out.println("missing currency: " + e.getClass().getSimpleName());
}
}
}
Output:
written: {"number":"INV-1","lines":[{"amount":"19.90","currency":"EUR"},{"amount":"1000","currency":"EUR"}]}
1E+3 read back as 1000: equals false, compareTo 0
default: {"number":"INV-1","lines":[{"amount":19.90,"currency":"EUR"},{"amount":1E+3,"currency":"EUR"}]}
missing currency: InvalidFormatException
- With the custom serializer,
wrotetoPlainString() . The default output wrote the JSON number1000 . That is valid JSON, but many consumers mishandle it, and JavaScript parses numbers as doubles, which loses precision for larger amounts. Strings keep money exact across languages.1E+3 - Reading back produced
, which1000 considers equal tocompareTo but1E+3 does not, becauseequals also compares the scale. Compare amounts withBigDecimal.equals , or normalize the scale in the record's constructor; seecompareToBigDecimal . - For a missing currency, the deserializer calls
, which throws Jackson's ownctxt.handleWeirdStringValue with the location in the input. A plainInvalidFormatException would give the caller far less to go on.NullPointerException - Passing
to theMoney.class constructor is not decoration: Jackson uses the handled type in error messages and schema generation.StdSerializer
Reading tokens directly
Reading a whole object as a tree is simple, but it builds nodes that are thrown away at once, and it hides the token structure you sometimes need. This deserializer reads tokens itself and accepts three shapes for the same value, as APIs that evolved over time often send: a string "19.90 EUR"
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.List;
public class TokenDeserializer {
record Money(BigDecimal amount, String currency) {
}
// Reads tokens directly and accepts three input shapes for the same value
static class MoneyDeserializer extends StdDeserializer<Money> {
MoneyDeserializer() {
super(Money.class);
}
@Override
public Money deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
JsonToken t = p.currentToken();
if (t == JsonToken.VALUE_STRING) { // "19.90 EUR"
String[] parts = p.getText().trim().split("\\s+");
if (parts.length != 2) {
return (Money) ctxt.handleWeirdStringValue(Money.class, p.getText(),
"expected \"<amount> <currency>\"");
}
return new Money(new BigDecimal(parts[0]), parts[1]);
}
if (t == JsonToken.VALUE_NUMBER_INT || t == JsonToken.VALUE_NUMBER_FLOAT) {
return new Money(p.getDecimalValue(), "EUR"); // 19.90, default currency
}
if (t == JsonToken.START_OBJECT) { // {"amount":..,"currency":..}
BigDecimal amount = null;
String currency = null;
while (p.nextToken() == JsonToken.FIELD_NAME) {
String name = p.currentName();
p.nextToken(); // move to the value
switch (name) {
case "amount" -> amount = new BigDecimal(p.getValueAsString());
case "currency" -> currency = p.getValueAsString();
default -> p.skipChildren(); // ignore anything else
}
}
if (amount == null || currency == null) {
return (Money) ctxt.reportInputMismatch(this,
"Money needs amount and currency, got amount=%s currency=%s",
amount, currency);
}
return new Money(amount, currency);
}
return (Money) ctxt.handleUnexpectedToken(Money.class, p);
}
}
record Order(String id, List<Money> prices) {
}
public static void main(String[] args) throws Exception {
ObjectMapper mapper = JsonMapper.builder().addModule(new SimpleModule()
.addDeserializer(Money.class, new MoneyDeserializer())).build();
String json = """
{"id": "o1", "prices": [
"19.90 EUR",
0.1,
{"amount": "5.00", "currency": "USD", "note": {"nested": [1, 2]}}
]}""";
mapper.readValue(json, Order.class).prices().forEach(m -> System.out.println(m));
for (String bad : new String[] {"[\"19.90\"]", "[{\"amount\":\"1\"}]", "[true]"}) {
try {
mapper.readValue("{\"id\":\"x\",\"prices\":" + bad + "}", Order.class);
} catch (Exception e) {
System.out.println(bad + " -> " + e.getClass().getSimpleName() + ": "
+ e.getMessage().lines().findFirst().orElse(""));
}
}
}
}
Output:
Money[amount=19.90, currency=EUR]
Money[amount=0.1, currency=EUR]
Money[amount=5.00, currency=USD]
["19.90"] -> InvalidFormatException: Cannot deserialize value of type `TokenDeserializer$Money` from String "19.90": expected "<amount> <currency>"
[{"amount":"1"}] -> MismatchedInputException: Money needs amount and currency, got amount=1 currency=null
[true] -> MismatchedInputException: Cannot deserialize value of type `TokenDeserializer$Money` from Boolean value (token `JsonToken.VALUE_TRUE`)
- When
is called, the parser is already on the value's first token, sodeserialize tells the three shapes apart:currentToken() , a number token, orVALUE_STRING .START_OBJECT - For a number,
returned exactlygetDecimalValue() : Jackson builds the0.1 from the number's text, so noBigDecimal rounding is involved.double - Inside the object, the loop reads a field name, moves to its value with
, and handles it. For the unknownnextToken() field,note jumped over the whole nested object and array. Forgetting it leaves the parser insideskipChildren() , and the next loop iteration reads the wrong tokens.note - Each failure uses a context method, so the caller gets a proper Jackson exception:
gave anhandleWeirdStringValue with the expected format,InvalidFormatException areportInputMismatch with a formatted message, andMismatchedInputException the standard message for a boolean wherehandleUnexpectedToken was expected.Money
Per-field formats with @JsonDeserialize
Sometimes only some fields need special treatment. An older API sends booleans as the strings "Y""N"Boolean@JsonDeserializecontentUsing
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import java.io.IOException;
import java.util.List;
public class LegacyFormats {
// An old API sends booleans as "Y"/"N" and amounts in cents as strings
static class YesNoDeserializer extends StdDeserializer<Boolean> {
YesNoDeserializer() {
super(Boolean.class);
}
@Override
public Boolean deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
String text = p.getValueAsString();
return switch (text) {
case "Y" -> Boolean.TRUE;
case "N" -> Boolean.FALSE;
default -> (Boolean) ctxt.handleWeirdStringValue(Boolean.class, text,
"expected Y or N");
};
}
}
record Customer(
String id,
@JsonDeserialize(using = YesNoDeserializer.class) Boolean active,
@JsonDeserialize(using = YesNoDeserializer.class) Boolean newsletter,
@JsonDeserialize(contentUsing = YesNoDeserializer.class) List<Boolean> consents,
Boolean plain) { // no annotation: normal rules
}
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
System.out.println(mapper.readValue(
"{\"id\":\"c1\",\"active\":\"Y\",\"newsletter\":\"N\","
+ "\"consents\":[\"Y\",\"N\",\"Y\"],\"plain\":\"true\"}", Customer.class));
try {
mapper.readValue("{\"id\":\"c3\",\"plain\":\"Y\"}", Customer.class);
} catch (Exception e) {
String msg = e.getMessage();
System.out.println("plain \"Y\": " + e.getClass().getSimpleName() + ": "
+ msg.substring(0, msg.indexOf('\n')));
}
try {
mapper.readValue("{\"id\":\"c2\",\"active\":\"maybe\",\"newsletter\":\"N\"}",
Customer.class);
} catch (Exception e) {
String msg = e.getMessage();
System.out.println(e.getClass().getSimpleName() + ": "
+ msg.substring(0, msg.indexOf('\n')));
}
}
}
Output:
Customer[id=c1, active=true, newsletter=false, consents=[true, false, true], plain=true]
plain "Y": InvalidFormatException: Cannot deserialize value of type `java.lang.Boolean` from String "Y": only "true" or "false" recognized
InvalidFormatException: Cannot deserialize value of type `java.lang.Boolean` from String "maybe": expected Y or N
- "Y" and "N" became
andtrue in the two annotated fields, andfalse turned the listcontentUsing into["Y","N","Y"] . Use[true, false, true] for the field's own value,using for collection elements and map values, andcontentUsing for map keys.keyUsing - The unannotated
field kept Jackson's normal rules, which accept the stringplain but reject"true" with "only "true" or "false" recognized"."Y" - An unexpected value in an annotated field produced an error that names the target type, the bad value and the expected format.
Converters: custom mapping without token code
Often the JSON form of a type is just another type Jackson already knows: a DurationStdConvertercontentConverterJavaTimeModuleDuration
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.util.StdConverter;
import java.time.Duration;
import java.util.List;
import java.util.Map;
public class Converters {
// A converter maps to a type Jackson already knows; no token code needed
static class ToMinutes extends StdConverter<Duration, Long> {
@Override
public Long convert(Duration d) {
return d.toMinutes();
}
}
static class FromMinutes extends StdConverter<Long, Duration> {
@Override
public Duration convert(Long minutes) {
return Duration.ofMinutes(minutes);
}
}
static class CsvToList extends StdConverter<String, List<String>> {
@Override
public List<String> convert(String csv) {
return List.of(csv.split(","));
}
}
static class ListToCsv extends StdConverter<List<String>, String> {
@Override
public String convert(List<String> list) {
return String.join(",", list);
}
}
record Meeting(
String title,
@JsonSerialize(converter = ToMinutes.class)
@JsonDeserialize(converter = FromMinutes.class) Duration length,
@JsonSerialize(converter = ListToCsv.class)
@JsonDeserialize(converter = CsvToList.class) List<String> people,
@JsonSerialize(contentConverter = ToMinutes.class) Map<String, Duration> agenda) {
}
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
Meeting m = new Meeting("Planning", Duration.ofMinutes(90), List.of("ana", "ben"),
Map.of("intro", Duration.ofMinutes(10)));
String json = mapper.writeValueAsString(m);
System.out.println("written: " + json);
Meeting back = mapper.readValue(
"{\"title\":\"Retro\",\"length\":45,\"people\":\"cho,dee,eli\"}", Meeting.class);
System.out.println("read: " + back);
// Converters and serializers are not called for null values
Meeting withNulls = new Meeting("Empty", null, null, null);
System.out.println("nulls: " + mapper.writeValueAsString(withNulls));
}
}
Output:
written: {"title":"Planning","length":90,"people":"ana,ben","agenda":{"intro":10}}
read: Meeting[title=Retro, length=PT45M, people=[cho, dee, eli], agenda=null]
nulls: {"title":"Empty","length":null,"people":null,"agenda":null}
- A 90-minute
was written asDuration , and 45 was read back as90 . The list becamePT45M , and"ana,ben" became a list of three names."cho,dee,eli" converted each map value, so the agenda's 10-minute item becamecontentConverter .{"intro":10}- For
fields, neither converters nor serializers are called: Jackson writesnull itself. A serializer that wants to write something else for null has to be registered as a null serializer, or the field must be handled withnull .@JsonInclude - Converters need no knowledge of
orJsonGenerator , so they are the easiest custom code to write and test. Prefer them whenever the JSON form is a type Jackson already handles.JsonParser
Contextual serializers: one serializer, per-field settings
A serializer registered with @JsonSerialize(using = ...)ContextualSerializer
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.BeanProperty;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.ContextualSerializer;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import java.io.IOException;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
public class ContextualMask {
// Our own annotation, read by the serializer for each property it is used on
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER,
ElementType.RECORD_COMPONENT})
@interface Mask {
int keepLast() default 4;
}
static class MaskSerializer extends StdSerializer<String> implements ContextualSerializer {
static int created;
private final int keepLast;
MaskSerializer() {
this(0);
}
MaskSerializer(int keepLast) {
super(String.class);
this.keepLast = keepLast;
created++;
}
// Called once per property: return a serializer configured for that property
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) {
Mask mask = property == null ? null : property.getAnnotation(Mask.class);
return new MaskSerializer(mask == null ? 0 : mask.keepLast());
}
@Override
public void serialize(String value, JsonGenerator gen, SerializerProvider provider)
throws IOException {
int keep = Math.min(keepLast, value.length());
gen.writeString("*".repeat(value.length() - keep) + value.substring(value.length()
- keep));
}
}
record Payment(
String holder,
@Mask(keepLast = 4) @JsonSerialize(using = MaskSerializer.class) String card,
@Mask(keepLast = 2) @JsonSerialize(using = MaskSerializer.class) String phone,
@Mask(keepLast = 0) @JsonSerialize(using = MaskSerializer.class) String cvc) {
}
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
Payment p = new Payment("Ana Lima", "4111111111111111", "5551234", "123");
System.out.println(mapper.writeValueAsString(p));
for (int i = 0; i < 1000; i++) {
mapper.writeValueAsString(p);
}
System.out.println("serializer instances after 1001 writes: " + MaskSerializer.created);
}
}
Output:
{"holder":"Ana Lima","card":"************1111","phone":"*****34","cvc":"***"}
serializer instances after 1001 writes: 6
- The custom
annotation set how many characters stay visible: the card kept its last 4,@Mask , the phone its last 2, and the CVC none.1111 runs once per property when the serializer is first looked up, not on every write. After 1,001 writes there were only 6 instances: 3 created by Jackson from thecreateContextual class, and 3 configured copies returned byusing , one per field.createContextual- Because instances are cached and shared, settings must be stored in final fields, as
is. A mutable field set duringkeepLast would be shared by every thread. The staticserialize counter here exists only to show the instance count.created - Deserializers have the same mechanism,
, which is how a deserializer can read aContextualDeserializer pattern from its field.@JsonFormat
Modifiers, and the recursion trap
A BeanSerializerModifier
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.BeanDescription;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationConfig;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.BeanPropertyWriter;
import com.fasterxml.jackson.databind.ser.BeanSerializerModifier;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import java.io.IOException;
import java.util.List;
public class Modifier {
record User(String name, String password, String apiSecret) {
}
record Service(String url, String clientSecret) {
}
// Applies to every bean type: drop any property whose name mentions a secret
static class DropSecrets extends BeanSerializerModifier {
@Override
public List<BeanPropertyWriter> changeProperties(SerializationConfig config,
BeanDescription desc, List<BeanPropertyWriter> props) {
props.removeIf(p -> p.getName().toLowerCase().matches(".*(password|secret).*"));
return props;
}
}
record Order(long id, double total) {
}
// Trap: a serializer that hands the same value back to Jackson calls itself again
static class Recursive extends StdSerializer<Order> {
Recursive() {
super(Order.class);
}
@Override
public void serialize(Order o, JsonGenerator gen, SerializerProvider p)
throws IOException {
gen.writeObject(o);
}
}
// Fix: write the fields yourself (or look up a different type's serializer)
static class Wrapping extends StdSerializer<Order> {
Wrapping() {
super(Order.class);
}
@Override
public void serialize(Order o, JsonGenerator gen, SerializerProvider p)
throws IOException {
gen.writeStartObject();
gen.writeNumberField("id", o.id());
gen.writeNumberField("total", o.total());
gen.writeStringField("_link", "/orders/" + o.id());
gen.writeEndObject();
}
}
static ObjectMapper with(JsonSerializer<Order> ser) {
return JsonMapper.builder().addModule(new SimpleModule().addSerializer(Order.class, ser))
.build();
}
public static void main(String[] args) throws Exception {
ObjectMapper mapper = JsonMapper.builder()
.addModule(new SimpleModule().setSerializerModifier(new DropSecrets())).build();
System.out.println(mapper.writeValueAsString(new User("ana", "hunter2", "s-1")));
System.out.println(mapper.writeValueAsString(new Service("https://x.io", "c-9")));
try {
with(new Recursive()).writeValueAsString(new Order(7, 9.5));
} catch (Throwable e) {
System.out.println("recursive serializer: " + e.getClass().getSimpleName());
}
System.out.println("wrapping serializer: "
+ with(new Wrapping()).writeValueAsString(new Order(7, 9.5)));
}
}
Output:
{"name":"ana"}
{"url":"https://x.io"}
recursive serializer: StackOverflowError
wrapping serializer: {"id":7,"total":9.5,"_link":"/orders/7"}
- The modifier removed
andpassword fromapiSecret andUser fromclientSecret , without any annotation on either record. Any class added later gets the same treatment, which makes a modifier a good safety net next toService .@JsonIgnore - The trap:
callsRecursive with the samegen.writeObject(o) . Jackson looks up the serializer forOrder , findsOrder again, and the calls repeat until the thread runs out of stack:Recursive . It also happens when a serializer callsStackOverflowError on its own value.provider.defaultSerializeValue - The fix in
writes the fields itself and adds the extraWrapping property. When a serializer only needs to add a field to Jackson's default output, a modifier that wraps the default serializer, or_link , avoids repeating every field by hand.@JsonAppend
What a custom deserializer costs
A tree-reading deserializer builds an ObjectNode
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectReader;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.List;
public class DeserializerSpeed {
record Money(BigDecimal amount, String currency) {
}
static class ViaTree extends StdDeserializer<Money> {
ViaTree() {
super(Money.class);
}
@Override
public Money deserialize(JsonParser p, DeserializationContext c) throws IOException {
JsonNode n = p.readValueAsTree();
return new Money(new BigDecimal(n.get("amount").asText()), n.get("currency").asText());
}
}
static class ViaTokens extends StdDeserializer<Money> {
ViaTokens() {
super(Money.class);
}
@Override
public Money deserialize(JsonParser p, DeserializationContext c) throws IOException {
BigDecimal amount = null;
String currency = null;
while (p.nextToken() == JsonToken.FIELD_NAME) {
String name = p.currentName();
p.nextToken();
if (name.equals("amount")) {
amount = new BigDecimal(p.getText());
} else if (name.equals("currency")) {
currency = p.getText();
} else {
p.skipChildren();
}
}
return new Money(amount, currency);
}
}
static ObjectReader reader(StdDeserializer<Money> d) {
JsonMapper.Builder b = JsonMapper.builder();
if (d != null) {
b.addModule(new SimpleModule().addDeserializer(Money.class, d));
}
return b.build().readerFor(new TypeReference<List<Money>>() { });
}
static long time(ObjectReader r, String json) throws Exception {
long t0 = System.nanoTime();
List<Money> list = r.readValue(json);
long t = System.nanoTime() - t0;
if (list.size() != 200_000) {
throw new AssertionError();
}
return t;
}
public static void main(String[] args) throws Exception {
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < 200_000; i++) {
sb.append(i == 0 ? "" : ",").append("{\"amount\":\"").append(i % 1000).append('.')
.append(i % 100).append("\",\"currency\":\"EUR\"}");
}
String json = sb.append(']').toString();
System.out.printf("%,d Money objects, %,d characters%n", 200_000, json.length());
String[] names = {"default record binding", "custom, reads a tree",
"custom, reads tokens"};
ObjectReader[] readers = {reader(null), reader(new ViaTree()), reader(new ViaTokens())};
long[] best = {Long.MAX_VALUE, Long.MAX_VALUE, Long.MAX_VALUE};
// Rounds alternate between the three, so no one benefits from running last;
// the first two rounds are warm-up and are not counted
for (int round = 0; round < 5; round++) {
for (int i = 0; i < readers.length; i++) {
long t = time(readers[i], json);
if (round >= 2) {
best[i] = Math.min(best[i], t);
}
}
}
for (int i = 0; i < readers.length; i++) {
System.out.printf("%-24s %4d ms (best of 3)%n", names[i] + ":", best[i] / 1_000_000);
}
System.out.println("second value: " + reader(new ViaTokens()).<List<Money>>readValue(json)
.get(1));
}
}
Output of the deserializer timing run:
200,000 Money objects, 7,358,001 characters
default record binding: 43 ms (best of 3)
custom, reads a tree: 96 ms (best of 3)
custom, reads tokens: 25 ms (best of 3)
second value: Money[amount=1.1, currency=EUR]
- Across several runs, the token-reading deserializer was the fastest of the three each time. The tree version and the default binding changed places from run to run, so their difference is within the noise here.
- All three handle 200,000 objects in about a tenth of a second or less, so for normal payloads correctness matters far more than this cost. Reach for token code when a deserializer sits on a hot path or must accept several input shapes; use a tree or a converter otherwise.
Which tool to use
| Need | Simplest option |
|---|---|
| Rename, hide or reformat fields | Annotations; see |
| A type with a natural single-value form, such as an ID | @JsonValue@JsonCreator |
| The JSON form is a type Jackson already knows | StdConverterconvertercontentConverter |
| A different JSON shape for one type everywhere | StdSerializerStdDeserializerSimpleModule |
| Several accepted input shapes | A token-reading StdDeserializer |
| A different format for selected fields only | @JsonSerialize@JsonDeserializeusingcontentUsingkeyUsing |
| One serializer with per-field options | ContextualSerializer |
| A rule for every class | BeanSerializerModifier |
| Restructure a whole document | Read it as a |
Whatever you choose, keep serializers and deserializers stateless, or store settings only in final fields, because one instance is shared by all threads using the mapper.