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.
Contents
What a Jackson module is
An ObjectMapperaddModuleregisterModulesetupModule
Modules are how Jackson 2 stays small and backwards compatible. Support for java.timeOptionaljackson-databind
| Module | Artifact | Adds | In Jackson 3 |
|---|---|---|---|
JavaTimeModule | jackson-datatype-jsr310 | java.time | Built in |
Jdk8Module | jackson-datatype-jdk8 | OptionalOptionalIntOptionalLongOptionalDouble | Built in |
ParameterNamesModule | jackson-module-parameter-names | Constructor parameter names, for classes compiled with -parameters | Built in |
KotlinModule | jackson-module-kotlin | Kotlin data classes, default values, nullability | Separate module |
BlackbirdModule | jackson-module-blackbird | Faster property access using method handles instead of reflection | Separate module |
Your own SimpleModule | Your code | Serializers for your domain types, in one reusable place | Same 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.timeOptional
java.time and Optional together
The booking record below uses five types the core does not handle: LocalDateInstantZonedDateTimeDurationOptionalJavaTimeModuleJdk8Module
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:
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
, Jackson 2.21 refusesJdk8Module withOptional . Older 2.x versions silently wrote it as an object with aInvalidDefinitionException field. With the module, a presentpresent is written as its value.Optional - With dates as strings,
andLocalDate become ISO-8601 text andInstant becomesDuration , becausePT1H30M is disabled; otherwise it is a number of seconds.WRITE_DURATIONS_AS_TIMESTAMPS - The zone ID
was not written, only the offsetEurope/Oslo , and on reading, Jackson converted the time to UTC (+01:00 ). The instant is the same, but the object is not equal to the original, and the daylight saving rules for Oslo are lost. EnablingZ and disablingWRITE_DATES_WITH_ZONE_ID keeps the zone, as the last check shows.ADJUST_DATES_TO_CONTEXT_TIME_ZONE registers every Jackson module found on the classpath throughfindAndAddModules() ; here it found all three. It is convenient, but the mapper's behavior then depends on which JARs happen to be present, so explicitServiceLoader calls are easier to reason about.addModule
Date and time formats
With JavaTimeModulejava.timeInstant
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:
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
becomes decimal seconds with nine fraction digits,Instant , while1768469400.123000000 ,LocalDate andLocalDateTime become arrays such asLocalTime . Disabling[2026,3,1] writes epoch milliseconds,WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS , the format JavaScript's1768469400123 uses.Date.now() - The ISO strings are the clearest choice for APIs:
,"2026-01-15T09:30:00.123Z" and"2026-03-01" are readable and sort correctly as text."08:00:30" - The trap: reading the integer
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. Disabling1768469400123 makes integers mean milliseconds, and the same input gave the right instant.READ_DATE_TIMESTAMPS_AS_NANOSECONDS overrides the mapper for one field:@JsonFormat wrotepattern = "dd.MM.yyyy" , and"30.04.2026" wrote one instant as seconds even though the mapper writes strings. Both read back to an equal record.shape = NUMBER
For the java.time
Optional details
OptionalOptional<String>OptionalInt
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:
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
and an emptyOptional are written asOptionalInt .null inclusion did not remove them, because theNON_NULL object itself is not null.Optional , which treats an emptyNON_ABSENT likeOptional , removed both and leftnull .{"name":"Ben"}- When reading, an explicit
and a missing field both becamenull andOptional.empty , never a null reference, so code using the record does not need null checks on itsOptionalInt.empty components.Optional
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
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
parameter x, name in class file: true
parameter y, name in class file: true
without module: InvalidDefinitionException
with ParameterNamesModule: Point(3, 4)
is true, so the constructor's parameters really are calledisNamePresent() andx in the class file. Withouty they would be-parameters andarg0 .arg1- Even with the names present, the plain mapper failed with
: Jackson 2 core does not read parameter names.InvalidDefinitionException does, and the same JSON becameParameterNamesModule .Point(3, 4) - Both parts are needed. Compiled without
, 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.-parameters - 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
and each parameter with@JsonCreator , as shown on the@JsonPropertyObjectMapper page .
Writing your own module
When a domain type needs custom JSON everywhere, a SimpleModuleSku
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:
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,
. With it, the value is the string{"code":"a-1"} ."a-1" - JSON object keys are always strings, so a
needs aMap<Sku, Integer> ; the normal deserializer is not used for keys. Here the key deserializer upper-cases the codes, soKeyDeserializer became the"a-1" A-1. When writing, map keys useSku by default, which gavetoString() ; register a key serializer with"A-1" whenaddKeySerializer is not the right form.toString() - Registering the same module twice left one entry,
. Jackson identifies a module by its type id, and for a[sku-module] created with a name, that is the name.SimpleModule - When two modules register serializers for the same type, the one added last wins:
came from"A-1!" . Keep one owner per type, or a library's module can silently replace yours.loud
For the serializer classes themselves, including StdSerializer
Generic types: TypeReference and JavaType
Because of Page.class
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:
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
, Jackson does not know the item type and createsPage.class s. The unchecked cast toLinkedHashMap compiles and does not fail; thePage<User> appears later, at the first use of an item, which makes this bug hard to trace.ClassCastException creates an anonymous subclass whose generic superclass records the full type, and Jackson reads it from there.new TypeReference<Page<User>>() { }- Generic code that only has
objects at run time builds the type withClass , as in a methodconstructParametricType . The type factory also has<T> Page<T> readPage(String json, Class<T> itemType) andconstructCollectionType for the common cases.constructMapType - Nested generics such as
work the same way, and the list elements were realMap<String, List<Integer>> s.Integer
Which module or technique to use
| Need | Use |
|---|---|
java.time | JavaTimeModuledisable(WRITE_DATES_AS_TIMESTAMPS) |
| Epoch milliseconds from JavaScript | Disable READ_DATE_TIMESTAMPS_AS_NANOSECONDSWRITE_DATE_TIMESTAMPS_AS_NANOSECONDS |
Keep a ZonedDateTime | WRITE_DATES_WITH_ZONE_IDADJUST_DATES_TO_CONTEXT_TIME_ZONE |
| A different date format for one field | @JsonFormat(pattern = ...) |
Optional | Jdk8ModuleNON_ABSENT |
| Immutable classes without annotations | ParameterNamesModule-parameters |
| Custom JSON for your own types | A SimpleModule |
Generic types such as List<User> | TypeReferenceJavaType |
| Jackson 3 | Nothing for java.timeOptional |