Jackson Modules: JavaTimeModule, Parameterized Types & Type Handling

Jackson's core only knows the types that existed when it was designed. Modules teach a mapper new types, such as java.time and Optional, and new tricks, such as reading constructor parameter names. This page explains how modules plug into a mapper, runs the standard ones with their pitfalls, builds a module of your own, and covers generic types, which no module can fix.

Share

Contents

What a Jackson module is

An ObjectMapper finds a serializer for a type by asking a chain of providers. A module is a bundle of additions to that chain, registered once with addModule (or registerModule on an old-style mapper). When the mapper is built, it calls the module's setupModule method, and the module adds whatever it brings: serializers and deserializers for specific types, key deserializers for map keys, mix-in annotations, rules for creating objects, or modifiers that change how every bean is handled.

Modules are how Jackson 2 stays small and backwards compatible. Support for java.time (Java 8) and Optional could not go into the core without breaking code that already handled those types its own way, so they shipped as separate artifacts. Jackson 3 moved the most common ones into jackson-databind.

Common Jackson 2 modules
ModuleArtifactAddsIn Jackson 3
JavaTimeModulejackson-datatype-jsr310java.time typesBuilt in
Jdk8Modulejackson-datatype-jdk8Optional, OptionalInt, OptionalLong, OptionalDoubleBuilt in
ParameterNamesModulejackson-module-parameter-namesConstructor parameter names, for classes compiled with -parametersBuilt in
KotlinModulejackson-module-kotlinKotlin data classes, default values, nullabilitySeparate module
BlackbirdModulejackson-module-blackbirdFaster property access using method handles instead of reflectionSeparate module
Your own SimpleModuleYour codeSerializers for your domain types, in one reusable placeSame idea

The examples use Jackson 2.21 on JDK 21 with the three standard datatype and parameter-name modules. The Jackson 3 column was checked with Jackson 3.1.7, where java.time, Optional and parameter names worked with no module registered.

java.time and Optional together

The booking record below uses five types the core does not handle: LocalDate, Instant, ZonedDateTime, Duration and Optional. The program serializes it with only JavaTimeModule, then with Jdk8Module added, reads it back, and finally configures the mapper to keep time zones.

Java
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.exc.InvalidDefinitionException;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZonedDateTime;
import java.util.Optional;

public class TimeAndOptional {

    record Booking(LocalDate day, Instant created, ZonedDateTime start, Duration length,
            Optional<String> note) {
    }

    public static void main(String[] args) throws Exception {
        Booking b = new Booking(LocalDate.of(2026, 3, 1), Instant.parse("2026-01-15T09:30:00Z"),
                ZonedDateTime.parse("2026-03-01T14:00:00+01:00[Europe/Oslo]"),
                Duration.ofMinutes(90), Optional.of("window seat"));

        // Without Jdk8Module, Optional is rejected
        ObjectMapper timeOnly = JsonMapper.builder().addModule(new JavaTimeModule())
                .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS).build();
        try {
            timeOnly.writeValueAsString(b);
        } catch (InvalidDefinitionException e) {
            System.out.println("no Jdk8Module: InvalidDefinitionException for Optional");
        }

        JsonMapper full = JsonMapper.builder()
                .addModule(new JavaTimeModule())
                .addModule(new Jdk8Module())
                .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
                .disable(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS)
                .build();
        String json = full.writeValueAsString(b);
        System.out.println("with both modules:\n  " + json);

        Booking back = full.readValue(json, Booking.class);
        System.out.println("zone kept after reading: " + back.start().getZone());
        System.out.println("same instant: "
                + back.start().toInstant().equals(b.start().toInstant())
                + ", equal objects: " + back.equals(b));

        // Keep the zone: write its ID and do not convert to UTC when reading
        ObjectMapper zoned = full.rebuild()
                .enable(SerializationFeature.WRITE_DATES_WITH_ZONE_ID)
                .disable(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE)
                .build();
        String zonedJson = zoned.writeValueAsString(b.start());
        System.out.println("with zone ID: " + zonedJson + ", read back equal: "
                + zoned.readValue(zonedJson, ZonedDateTime.class).equals(b.start()));

        // findAndAddModules registers every module found on the classpath
        ObjectMapper auto = JsonMapper.builder().findAndAddModules().build();
        System.out.println("modules found automatically: " + auto.getRegisteredModuleIds());
    }
}

Output:

Output
no Jdk8Module: InvalidDefinitionException for Optional
with both modules:
  {"day":"2026-03-01","created":"2026-01-15T09:30:00Z","start":"2026-03-01T14:00:00+01:00","length":"PT1H30M","note":"window seat"}
zone kept after reading: Z
same instant: true, equal objects: false
with zone ID: "2026-03-01T14:00:00+01:00[Europe/Oslo]", read back equal: true
modules found automatically: [com.fasterxml.jackson.datatype.jdk8.Jdk8Module, jackson-datatype-jsr310, jackson-module-parameter-names]
  • Without Jdk8Module, Jackson 2.21 refuses Optional with InvalidDefinitionException. Older 2.x versions silently wrote it as an object with a present field. With the module, a present Optional is written as its value.
  • With dates as strings, LocalDate and Instant become ISO-8601 text and Duration becomes PT1H30M, because WRITE_DURATIONS_AS_TIMESTAMPS is disabled; otherwise it is a number of seconds.
  • The zone ID Europe/Oslo was not written, only the offset +01:00, and on reading, Jackson converted the time to UTC (Z). The instant is the same, but the object is not equal to the original, and the daylight saving rules for Oslo are lost. Enabling WRITE_DATES_WITH_ZONE_ID and disabling ADJUST_DATES_TO_CONTEXT_TIME_ZONE keeps the zone, as the last check shows.
  • findAndAddModules() registers every Jackson module found on the classpath through ServiceLoader; here it found all three. It is convenient, but the mapper's behavior then depends on which JARs happen to be present, so explicit addModule calls are easier to reason about.

Date and time formats

With JavaTimeModule, every java.time value has two possible shapes: a "timestamp" (numbers) or an ISO-8601 string. The record holds an Instant with milliseconds and three local types, because each is written differently as a timestamp. The program also reads one instant in three forms, which exposes the most common date bug with Jackson.

Java
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;

public class TimeFormats {

    record Times(Instant created, LocalDate day, LocalDateTime local, LocalTime at) {
    }

    // Per-field formats override the mapper-wide setting
    record Invoice(@JsonFormat(pattern = "dd.MM.yyyy") LocalDate due,
            @JsonFormat(shape = JsonFormat.Shape.NUMBER) Instant paid) {
    }

    public static void main(String[] args) throws Exception {
        Times t = new Times(Instant.parse("2026-01-15T09:30:00.123Z"), LocalDate.of(2026, 3, 1),
                LocalDateTime.of(2026, 3, 1, 14, 5), LocalTime.of(8, 0, 30));

        ObjectMapper timestamps = JsonMapper.builder().addModule(new JavaTimeModule()).build();
        System.out.println("timestamps (default): " + timestamps.writeValueAsString(t));

        ObjectMapper millis = JsonMapper.builder().addModule(new JavaTimeModule())
                .disable(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS).build();
        System.out.println("timestamps as millis: " + millis.writeValueAsString(t));

        ObjectMapper iso = JsonMapper.builder().addModule(new JavaTimeModule())
                .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS).build();
        System.out.println("ISO-8601 strings:     " + iso.writeValueAsString(t));

        // Reading accepts several forms for the same Instant
        for (String json : new String[] {"\"2026-01-15T09:30:00.123Z\"", "1768469400.123",
                "1768469400123"}) {
            System.out.printf("read %-28s -> %s%n", json, iso.readValue(json, Instant.class));
        }

        // An integer is read as seconds unless nanosecond reading is turned off
        ObjectMapper millisReader = JsonMapper.builder().addModule(new JavaTimeModule())
                .disable(DeserializationFeature.READ_DATE_TIMESTAMPS_AS_NANOSECONDS).build();
        System.out.println("as millis: 1768469400123 -> "
                + millisReader.readValue("1768469400123", Instant.class));

        Invoice inv = new Invoice(LocalDate.of(2026, 4, 30), Instant.parse("2026-04-02T10:00:00Z"));
        String json = iso.writeValueAsString(inv);
        System.out.println("@JsonFormat fields:   " + json);
        System.out.println("read back equal:      "
                + iso.readValue(json, Invoice.class).equals(inv));
    }
}

Output:

Output
timestamps (default): {"created":1768469400.123000000,"day":[2026,3,1],"local":[2026,3,1,14,5],"at":[8,0,30]}
timestamps as millis: {"created":1768469400123,"day":[2026,3,1],"local":[2026,3,1,14,5],"at":[8,0,30]}
ISO-8601 strings:     {"created":"2026-01-15T09:30:00.123Z","day":"2026-03-01","local":"2026-03-01T14:05:00","at":"08:00:30"}
read "2026-01-15T09:30:00.123Z"   -> 2026-01-15T09:30:00.123Z
read 1768469400.123               -> 2026-01-15T09:30:00.123Z
read 1768469400123                -> +58010-07-25T20:02:03Z
as millis: 1768469400123 -> 2026-01-15T09:30:00.123Z
@JsonFormat fields:   {"due":"30.04.2026","paid":1775124000.000000000}
read back equal:      true
  • As timestamps, an Instant becomes decimal seconds with nine fraction digits, 1768469400.123000000, while LocalDate, LocalDateTime and LocalTime become arrays such as [2026,3,1]. Disabling WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS writes epoch milliseconds, 1768469400123, the format JavaScript's Date.now() uses.
  • The ISO strings are the clearest choice for APIs: "2026-01-15T09:30:00.123Z", "2026-03-01" and "08:00:30" are readable and sort correctly as text.
  • The trap: reading the integer 1768469400123 produced the year 58010. By default an integer is read as seconds, so a millisecond value from a JavaScript client lands about 56,000 years in the future without any error. Disabling READ_DATE_TIMESTAMPS_AS_NANOSECONDS makes integers mean milliseconds, and the same input gave the right instant.
  • @JsonFormat overrides the mapper for one field: pattern = "dd.MM.yyyy" wrote "30.04.2026", and shape = NUMBER wrote one instant as seconds even though the mapper writes strings. Both read back to an equal record.

For the java.time classes themselves, see date and time in Java.

Optional details

Optional fields raise two questions: what is written for an empty one, and what is read when the field is null or missing. The profile record has an Optional<String> and an OptionalInt, so both kinds of optional are covered.

Java
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
import java.util.Optional;
import java.util.OptionalInt;

public class OptionalDetails {

    record Profile(String name, Optional<String> nickname, OptionalInt age) {
    }

    public static void main(String[] args) throws Exception {
        JsonMapper mapper = JsonMapper.builder().addModule(new Jdk8Module()).build();

        Profile full = new Profile("Ana", Optional.of("an"), OptionalInt.of(30));
        Profile empty = new Profile("Ben", Optional.empty(), OptionalInt.empty());
        System.out.println("present: " + mapper.writeValueAsString(full));
        System.out.println("empty:   " + mapper.writeValueAsString(empty));

        // NON_ABSENT leaves out nulls and empty Optionals; NON_NULL only nulls
        for (JsonInclude.Include inc : new JsonInclude.Include[] {JsonInclude.Include.NON_NULL,
                JsonInclude.Include.NON_ABSENT}) {
            ObjectMapper m = mapper.rebuild()
                    .defaultPropertyInclusion(JsonInclude.Value.construct(inc, inc)).build();
            System.out.printf("%-10s: %s%n", inc, m.writeValueAsString(empty));
        }

        // Reading: an explicit null and a missing field both give an empty Optional
        System.out.println("null:    " + mapper.readValue(
                "{\"name\":\"Cho\",\"nickname\":null,\"age\":null}", Profile.class));
        System.out.println("missing: " + mapper.readValue("{\"name\":\"Dee\"}", Profile.class));
    }
}

Output:

Output
present: {"name":"Ana","nickname":"an","age":30}
empty:   {"name":"Ben","nickname":null,"age":null}
NON_NULL  : {"name":"Ben","nickname":null,"age":null}
NON_ABSENT: {"name":"Ben"}
null:    Profile[name=Cho, nickname=Optional.empty, age=OptionalInt.empty]
missing: Profile[name=Dee, nickname=Optional.empty, age=OptionalInt.empty]
  • An empty Optional and an empty OptionalInt are written as null.
  • NON_NULL inclusion did not remove them, because the Optional object itself is not null. NON_ABSENT, which treats an empty Optional like null, removed both and left {"name":"Ben"}.
  • When reading, an explicit null and a missing field both became Optional.empty and OptionalInt.empty, never a null reference, so code using the record does not need null checks on its Optional components.

Constructor parameter names

An immutable class with a two-argument constructor is the case where Jackson needs parameter names: it must know which JSON property goes to which argument. The page's programs are compiled with javac -parameters, which stores parameter names in the class file, and the program first checks that through reflection.

Java
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;
import java.lang.reflect.Constructor;
import java.lang.reflect.Parameter;

public class ParameterNames {

    // An immutable class with no Jackson annotations at all
    public static class Point {
        private final int x;
        private final int y;

        public Point(int x, int y) {
            this.x = x;
            this.y = y;
        }

        public int getX() {
            return x;
        }

        public int getY() {
            return y;
        }

        @Override
        public String toString() {
            return "Point(" + x + ", " + y + ")";
        }
    }

    public static void main(String[] args) throws Exception {
        Constructor<?> c = Point.class.getConstructor(int.class, int.class);
        for (Parameter p : c.getParameters()) {
            System.out.println("parameter " + p.getName() + ", name in class file: "
                    + p.isNamePresent());
        }

        String json = "{\"x\":3,\"y\":4}";
        try {
            new ObjectMapper().readValue(json, Point.class);
        } catch (Exception e) {
            System.out.println("without module: " + e.getClass().getSimpleName());
        }
        ObjectMapper withNames = JsonMapper.builder()
                .addModule(new ParameterNamesModule()).build();
        System.out.println("with ParameterNamesModule: " + withNames.readValue(json, Point.class));
    }
}

Output (compiled with -parameters):

Output
parameter x, name in class file: true
parameter y, name in class file: true
without module: InvalidDefinitionException
with ParameterNamesModule: Point(3, 4)
  • isNamePresent() is true, so the constructor's parameters really are called x and y in the class file. Without -parameters they would be arg0 and arg1.
  • Even with the names present, the plain mapper failed with InvalidDefinitionException: Jackson 2 core does not read parameter names. ParameterNamesModule does, and the same JSON became Point(3, 4).
  • Both parts are needed. Compiled without -parameters, the module has no names to read, and reading fails with "no Creators, like default constructor, exist". Maven and Gradle projects set the flag in the compiler configuration; Spring Boot's Maven parent POM and Gradle plugin turn it on by default.
  • Records do not need any of this, because the record component names are always stored in the class file. The alternative for normal classes is annotating the constructor with @JsonCreator and each parameter with @JsonProperty, as shown on the ObjectMapper page.

Writing your own module

When a domain type needs custom JSON everywhere, a SimpleModule keeps the serializer, deserializer and key deserializer together and registers them in one call. The program uses an Sku value type that should be written as a plain string, used both as a value and as a map key, since map keys go through a separate path.

Java
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.KeyDeserializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import java.io.IOException;
import java.util.Map;

public class CustomModule {

    // A value type from our own domain, with no Jackson annotations
    record Sku(String code) {
        @Override
        public String toString() {
            return code;
        }
    }

    static class SkuSerializer extends JsonSerializer<Sku> {
        @Override
        public void serialize(Sku v, JsonGenerator g, SerializerProvider p) throws IOException {
            g.writeString(v.code());
        }
    }

    static class SkuDeserializer extends JsonDeserializer<Sku> {
        @Override
        public Sku deserialize(JsonParser p, DeserializationContext c) throws IOException {
            return new Sku(p.getValueAsString().toUpperCase());
        }
    }

    // Map keys are always strings in JSON, so keys need their own deserializer
    static class SkuKeyDeserializer extends KeyDeserializer {
        @Override
        public Object deserializeKey(String key, DeserializationContext c) {
            return new Sku(key.toUpperCase());
        }
    }

    static SimpleModule skuModule() {
        return new SimpleModule("sku-module")
                .addSerializer(Sku.class, new SkuSerializer())
                .addDeserializer(Sku.class, new SkuDeserializer())
                .addKeyDeserializer(Sku.class, new SkuKeyDeserializer());
    }

    public static void main(String[] args) throws Exception {
        ObjectMapper plain = new ObjectMapper();
        System.out.println("no module:  " + plain.writeValueAsString(new Sku("a-1")));

        ObjectMapper mapper = JsonMapper.builder().addModule(skuModule()).build();
        System.out.println("with module: " + mapper.writeValueAsString(new Sku("a-1")));
        Map<Sku, Integer> stock = mapper.readValue("{\"a-1\":5,\"b-2\":0}",
                new TypeReference<Map<Sku, Integer>>() { });
        System.out.println("map with Sku keys: " + stock + ", key type "
                + stock.keySet().iterator().next().getClass().getSimpleName());
        System.out.println("written back: " + mapper.writeValueAsString(stock));

        // Registering a module twice is ignored; it is identified by its type id
        ObjectMapper twice = JsonMapper.builder().addModule(skuModule()).addModule(skuModule())
                .build();
        System.out.println("registered ids: " + twice.getRegisteredModuleIds());

        // Two different modules for the same type: the one added last wins
        SimpleModule loud = new SimpleModule("loud").addSerializer(Sku.class,
                new JsonSerializer<Sku>() {
                    @Override
                    public void serialize(Sku v, JsonGenerator g, SerializerProvider p)
                            throws IOException {
                        g.writeString(v.code().toUpperCase() + "!");
                    }
                });
        ObjectMapper both = JsonMapper.builder().addModule(skuModule()).addModule(loud).build();
        System.out.println("sku-module then loud: " + both.writeValueAsString(new Sku("a-1")));
    }
}

Output:

Output
no module:  {"code":"a-1"}
with module: "a-1"
map with Sku keys: {A-1=5, B-2=0}, key type Sku
written back: {"A-1":5,"B-2":0}
registered ids: [sku-module]
sku-module then loud: "A-1!"
  • Without the module, the record is written as an object, {"code":"a-1"}. With it, the value is the string "a-1".
  • JSON object keys are always strings, so a Map<Sku, Integer> needs a KeyDeserializer; the normal deserializer is not used for keys. Here the key deserializer upper-cases the codes, so "a-1" became the Sku A-1. When writing, map keys use toString() by default, which gave "A-1"; register a key serializer with addKeySerializer when toString() is not the right form.
  • Registering the same module twice left one entry, [sku-module]. Jackson identifies a module by its type id, and for a SimpleModule created with a name, that is the name.
  • When two modules register serializers for the same type, the one added last wins: "A-1!" came from loud. Keep one owner per type, or a library's module can silently replace yours.

For the serializer classes themselves, including StdSerializer and contextual serializers, see custom serializers and deserializers.

Generic types: TypeReference and JavaType

Because of type erasure, Page.class carries no information about what the page contains, and no module can restore it. The program reads the same JSON three ways.

Java
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.Map;

public class GenericTypes {

    record Page<T>(int page, List<T> items) {
    }

    record User(String name, int age) {
    }

    public static void main(String[] args) throws Exception {
        ObjectMapper mapper = new ObjectMapper();
        String json = "{\"page\":1,\"items\":[{\"name\":\"Ana\",\"age\":30},"
                + "{\"name\":\"Ben\",\"age\":25}]}";

        // Class literals cannot express Page<User>: the items become LinkedHashMaps
        Page<?> raw = mapper.readValue(json, Page.class);
        Object first = raw.items().get(0);
        System.out.println("Page.class item type: " + first.getClass().getSimpleName());
        try {
            @SuppressWarnings("unchecked")
            Page<User> wrong = (Page<User>) raw;
            String name = wrong.items().get(0).name();            // fails here, not above
            System.out.println(name);
        } catch (ClassCastException e) {
            System.out.println("using it as Page<User>: ClassCastException");
        }

        // TypeReference captures the full generic type
        Page<User> page = mapper.readValue(json, new TypeReference<Page<User>>() { });
        System.out.println("TypeReference item type: "
                + page.items().get(0).getClass().getSimpleName() + ", first name "
                + page.items().get(0).name());

        // JavaType, built at run time, for generic code that receives a Class<T>
        JavaType type = mapper.getTypeFactory().constructParametricType(Page.class, User.class);
        Page<User> viaJavaType = mapper.readValue(json, type);
        System.out.println("JavaType: " + viaJavaType.items());

        Map<String, List<Integer>> scores = mapper.readValue("{\"ana\":[90,85],\"ben\":[70]}",
                new TypeReference<Map<String, List<Integer>>>() { });
        System.out.println("nested generics: " + scores + ", element type "
                + scores.get("ana").get(0).getClass().getSimpleName());
    }
}

Output:

Output
Page.class item type: LinkedHashMap
using it as Page<User>: ClassCastException
TypeReference item type: User, first name Ana
JavaType: [User[name=Ana, age=30], User[name=Ben, age=25]]
nested generics: {ana=[90, 85], ben=[70]}, element type Integer
  • With Page.class, Jackson does not know the item type and creates LinkedHashMaps. The unchecked cast to Page<User> compiles and does not fail; the ClassCastException appears later, at the first use of an item, which makes this bug hard to trace.
  • new TypeReference<Page<User>>() { } creates an anonymous subclass whose generic superclass records the full type, and Jackson reads it from there.
  • Generic code that only has Class objects at run time builds the type with constructParametricType, as in a method <T> Page<T> readPage(String json, Class<T> itemType). The type factory also has constructCollectionType and constructMapType for the common cases.
  • Nested generics such as Map<String, List<Integer>> work the same way, and the list elements were real Integers.

Which module or technique to use

Choosing a module or technique
NeedUse
java.time on Jackson 2JavaTimeModule plus disable(WRITE_DATES_AS_TIMESTAMPS)
Epoch milliseconds from JavaScriptDisable READ_DATE_TIMESTAMPS_AS_NANOSECONDS and WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS
Keep a ZonedDateTime's zoneWRITE_DATES_WITH_ZONE_ID and disable ADJUST_DATES_TO_CONTEXT_TIME_ZONE
A different date format for one field@JsonFormat(pattern = ...)
Optional fieldsJdk8Module, with NON_ABSENT to omit empty ones
Immutable classes without annotationsParameterNamesModule and -parameters, or records
Custom JSON for your own typesA SimpleModule registered on every mapper
Generic types such as List<User>TypeReference, or JavaType in generic code
Jackson 3Nothing for java.time, Optional and parameter names: they are built in