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.

Share

Contents

Where custom serialization plugs in

To write a value, Jackson asks its SerializerProvider for a JsonSerializer for the value's type, then calls its serialize(value, generator, provider) method. The serializer writes tokens to a JsonGenerator: start an object, write a field name, write a string, end the object. Reading is the mirror image: a JsonDeserializer pulls tokens from a JsonParser and returns the value. Jackson creates each serializer once per type (or per property) and caches it, so one instance is shared by every thread that uses the mapper.

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.

Ways to customize how a type maps to JSON
MechanismScopeBest for
StdSerializer / StdDeserializer in a SimpleModuleEvery use of the type on that mapperValue types with their own JSON form, such as money
@JsonSerialize(using = ...) / @JsonDeserialize(using = ...)One field, or one class when placed on the classLegacy formats in selected fields
contentUsing, keyUsingElements or map keys of one fieldLists and maps of specially formatted values
StdConverter with converter = ...One field or typeMapping to a type Jackson already handles, without token code
ContextualSerializerEach property, configured by its annotationsOne serializer with per-field options
BeanSerializerModifierEvery bean typeCross-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.jackson, with JsonSerializer renamed to ValueSerializer and SerializerProvider to SerializationContext.

A serializer and deserializer for a value type

A Money value should appear in JSON as an object with the amount as an exact decimal string and the currency code. The serializer extends StdSerializer and writes tokens with the JsonGenerator; the deserializer extends StdDeserializer and reads the object as a tree. Both are registered with a SimpleModule, so every Money anywhere in an object graph uses them. The invoice has an ordinary 19.90 and 1E+3, which is how BigDecimal writes one thousand when it was created with a negative scale.

Java
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:

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, toPlainString() wrote 1000. The default output wrote the JSON number 1E+3. 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.
  • Reading back produced 1000, which compareTo considers equal to 1E+3 but equals does not, because BigDecimal.equals also compares the scale. Compare amounts with compareTo, or normalize the scale in the record's constructor; see BigDecimal.
  • For a missing currency, the deserializer calls ctxt.handleWeirdStringValue, which throws Jackson's own InvalidFormatException with the location in the input. A plain NullPointerException would give the caller far less to go on.
  • Passing Money.class to the StdSerializer constructor is not decoration: Jackson uses the handled type in error messages and schema generation.

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", a bare number, and an object with an extra nested field that must be skipped.

Java
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:

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 deserialize is called, the parser is already on the value's first token, so currentToken() tells the three shapes apart: VALUE_STRING, a number token, or START_OBJECT.
  • For a number, getDecimalValue() returned exactly 0.1: Jackson builds the BigDecimal from the number's text, so no double rounding is involved.
  • Inside the object, the loop reads a field name, moves to its value with nextToken(), and handles it. For the unknown note field, skipChildren() jumped over the whole nested object and array. Forgetting it leaves the parser inside note, and the next loop iteration reads the wrong tokens.
  • Each failure uses a context method, so the caller gets a proper Jackson exception: handleWeirdStringValue gave an InvalidFormatException with the expected format, reportInputMismatch a MismatchedInputException with a formatted message, and handleUnexpectedToken the standard message for a boolean where Money was expected.

Per-field formats with @JsonDeserialize

Sometimes only some fields need special treatment. An older API sends booleans as the strings "Y" and "N". Registering a deserializer globally for Boolean would affect every boolean in the application, so @JsonDeserialize attaches it to the fields that need it, and contentUsing applies it to each element of a list.

Java
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:

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 true and false in the two annotated fields, and contentUsing turned the list ["Y","N","Y"] into [true, false, true]. Use using for the field's own value, contentUsing for collection elements and map values, and keyUsing for map keys.
  • The unannotated plain field kept Jackson's normal rules, which accept the string "true" but reject "Y" with "only "true" or "false" recognized".
  • 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 Duration as a number of minutes, a list as one comma-separated string. A StdConverter maps the value, and Jackson then serializes the result normally. The meeting record uses both, plus contentConverter for map values. No JavaTimeModule is registered, so the converters are the only reason Duration works.

Java
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:

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 Duration was written as 90, and 45 was read back as PT45M. The list became "ana,ben", and "cho,dee,eli" became a list of three names.
  • contentConverter converted each map value, so the agenda's 10-minute item became {"intro":10}.
  • For null fields, neither converters nor serializers are called: Jackson writes null 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 with @JsonInclude.
  • Converters need no knowledge of JsonGenerator or JsonParser, so they are the easiest custom code to write and test. Prefer them whenever the JSON form is a type Jackson already handles.

Contextual serializers: one serializer, per-field settings

A serializer registered with @JsonSerialize(using = ...) is the same for every field, but fields often need different settings. Implementing ContextualSerializer lets a serializer read the annotations of the property it is about to serve and return a configured copy. The payment record masks a card number, a phone number and a CVC with different numbers of visible digits.

Java
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:

Output
{"holder":"Ana Lima","card":"************1111","phone":"*****34","cvc":"***"}
serializer instances after 1001 writes: 6
  • The custom @Mask annotation set how many characters stay visible: the card kept its last 4, 1111, the phone its last 2, and the CVC none.
  • createContextual 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 the using class, and 3 configured copies returned by createContextual, one per field.
  • Because instances are cached and shared, settings must be stored in final fields, as keepLast is. A mutable field set during serialize would be shared by every thread. The static created counter here exists only to show the instance count.
  • Deserializers have the same mechanism, ContextualDeserializer, which is how a deserializer can read a @JsonFormat pattern from its field.

Modifiers, and the recursion trap

A BeanSerializerModifier sees every bean serializer Jackson builds and can change its list of properties, which suits rules that apply across all classes. The program drops every property whose name mentions a password or secret, then shows what happens when a serializer hands its own value back to Jackson.

Java
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:

Output
{"name":"ana"}
{"url":"https://x.io"}
recursive serializer: StackOverflowError
wrapping serializer:  {"id":7,"total":9.5,"_link":"/orders/7"}
  • The modifier removed password and apiSecret from User and clientSecret from Service, without any annotation on either record. Any class added later gets the same treatment, which makes a modifier a good safety net next to @JsonIgnore.
  • The trap: Recursive calls gen.writeObject(o) with the same Order. Jackson looks up the serializer for Order, finds Recursive again, and the calls repeat until the thread runs out of stack: StackOverflowError. It also happens when a serializer calls provider.defaultSerializeValue on its own value.
  • The fix in Wrapping writes the fields itself and adds the extra _link property. When a serializer only needs to add a field to Jackson's default output, a modifier that wraps the default serializer, or @JsonAppend, avoids repeating every field by hand.

What a custom deserializer costs

A tree-reading deserializer builds an ObjectNode for every value. The program reads an array of 200,000 money objects with Jackson's default record binding, a tree-reading deserializer and a token-reading deserializer. The three alternate in five rounds, the first two are warm-up, and the best of the remaining three is shown. This is a rough comparison on a busy 2-CPU Linux machine with JDK 21; see the JMH page for real benchmarks.

Java
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:

Output
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

Options for changing how a type maps to JSON
NeedSimplest option
Rename, hide or reformat fieldsAnnotations; see annotations and mix-ins
A type with a natural single-value form, such as an ID@JsonValue on the getter and @JsonCreator on a factory method
The JSON form is a type Jackson already knowsStdConverter with converter or contentConverter
A different JSON shape for one type everywhereStdSerializer and StdDeserializer in a SimpleModule; see modules
Several accepted input shapesA token-reading StdDeserializer
A different format for selected fields only@JsonSerialize / @JsonDeserialize with using, contentUsing or keyUsing
One serializer with per-field optionsContextualSerializer reading your own annotation
A rule for every classBeanSerializerModifier
Restructure a whole documentRead it as a tree and map the parts

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.