Jackson โ€” JSON in Java

The de-facto standard for JSON serialisation in Java โ€” how it works, how Spring Boot configures it, and the gotchas that will bite you in production

← Back to Index

What is Jackson, and Why Does Every Java Project Use It?

Jackson is a Java library for processing JSON โ€” converting Java objects to JSON strings (serialisation) and JSON strings back to Java objects (deserialisation). It is not part of the JDK, not part of the Java EE / Jakarta EE spec, and not written by Spring โ€” it is an independent open-source library that became the industry default because it is fast, flexible, and deeply integrated into every major Java framework.

Before Jackson, Java had no standard way to handle JSON. Projects used string concatenation, home-grown parsers, or the Jakarta EE JSON-P/JSON-B APIs (which are correct but verbose and less widely adopted). Jackson solved the problem once and solved it well โ€” its ObjectMapper can convert any Java object to JSON and back with zero configuration for the common case, and precise control when needed.

/*
 * Before Jackson โ€” manual JSON construction (real pre-2010 code):
 *
 *   String json = "{\"name\":\"" + user.getName() + "\","
 *               + "\"email\":\"" + user.getEmail() + "\"}";
 *
 * Problem: escaping, null handling, nested objects, arrays โ€” all manual.
 * No type safety, no refactoring support, wrong JSON on one typo.
 *
 * With Jackson:
 */
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(user);          // โ†’ {"name":"Ana","email":"ana@example.com"}
User parsed = mapper.readValue(json, User.class);       // back to User
Jackson in Spring Boot โ€” you're already using it

spring-boot-starter-web pulls in jackson-databind as a transitive dependency. Spring Boot auto-configures an ObjectMapper bean and registers it as the HttpMessageConverter for application/json โ€” every @RestController method that returns an object uses Jackson under the hood. You've been using it since page one of this Bible; this page explains how it actually works.

Three Jackson modules โ€” know which one does what
ModuleWhat it does
jackson-core Low-level streaming API (JsonParser, JsonGenerator) โ€” you rarely use this directly
jackson-databind ObjectMapper and data binding โ€” this is "Jackson" for 99% of use cases
jackson-annotations All the @Json* annotations โ€” pulled in transitively by databind

ObjectMapper โ€” The Core, and Why You Don't new It Per Request

// ObjectMapper is THREAD-SAFE after configuration โ€” create once, reuse everywhere
@Bean
public ObjectMapper objectMapper() {
    return new ObjectMapper()
        .registerModule(new JavaTimeModule())                         // LocalDate, LocalDateTime, etc.
        .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)      // ISO-8601 strings, not epoch longs
        .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)   // ignore extra JSON fields
        .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES);    // "Name" binds to "name"
}

// Basic operations
ObjectMapper mapper = new ObjectMapper();

// Serialise โ€” object to JSON string
String json        = mapper.writeValueAsString(user);
String prettyJson  = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(user);

// Deserialise โ€” JSON string to object
User user          = mapper.readValue(json, User.class);

// Generic types โ€” List, Map, Page<T> require TypeReference
List<User> users   = mapper.readValue(json, new TypeReference<List<User>>() {});

// Convert between types (e.g. Map โ†’ POJO, useful for dynamic structures)
Map<String, Object> map = mapper.convertValue(user, new TypeReference<>() {});
User fromMap       = mapper.convertValue(map, User.class);
new ObjectMapper() per request is a real performance bug

ObjectMapper construction is expensive โ€” it scans the classpath, builds internal caches for every type it encounters, and registers modules. Creating a new instance per HTTP request in a high- traffic API measurably degrades throughput. The instance itself is fully thread-safe after configuration; share one instance (or use the Spring-managed bean) across the entire application.

Core Annotations

public class UserDto {

    @JsonProperty("full_name")          // serialises as "full_name" in JSON, even though the field is "name"
    private String name;

    @JsonIgnore                          // excluded from both serialisation AND deserialisation
    private String passwordHash;

    @JsonAlias({"mail", "emailAddress"})  // accepts any of these names when deserialising; serialises as "email"
    private String email;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "UTC")
    private LocalDateTime createdAt;

    @JsonInclude(JsonInclude.Include.NON_NULL)  // field omitted from JSON when value is null
    private String middleName;

    @JsonAnyGetter                       // serialises a Map's entries as top-level JSON properties
    private Map<String, Object> extra = new HashMap<>();
}
// Class-level annotations
@JsonIgnoreProperties(ignoreUnknown = true)    // class-level equivalent of FAIL_ON_UNKNOWN_PROPERTIES=false
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)  // camelCase โ†’ snake_case globally for this class
public class ExternalApiResponse {
    private String firstName;   // serialised as "first_name" automatically
    private String lastName;    // serialised as "last_name" automatically
}
@JsonInclude at the mapper level vs per-class

Setting @JsonInclude(NON_NULL) on a single class controls only that class. Applying it globally via mapper.setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL) affects every type the mapper serialises. Spring Boot exposes this as spring.jackson.default-property-inclusion=non_null in application.properties โ€” cleaner than subclassing ObjectMapper.

Records and Jackson โ€” Java 17+ Idiom

Java records are immutable by construction โ€” no setters, all fields set via the canonical constructor. Jackson supports records natively from jackson-databind 2.12+ (which corresponds to Spring Boot 2.4+ โ€” all Boot 3.x versions qualify).

// Records deserialise via constructor โ€” no default constructor needed, no setters needed
public record UserResponse(
    Long id,
    String name,
    @JsonProperty("email_address") String email,  // annotations on record components work
    @JsonFormat(pattern = "yyyy-MM-dd") LocalDate dob
) {}
Records need ParameterNamesModule โ€” or Boot handles it for you

Jackson maps JSON keys to constructor parameters by name. Constructor parameter names are only available in bytecode if the -parameters compiler flag was used (same caveat as @PathVariable in Spring MVC โ€” see Spring MVC). Without it, Jackson needs @JsonCreator + @JsonProperty on every constructor parameter to know the mapping. Spring Boot's auto- configured ObjectMapper registers ParameterNamesModule automatically and enables -parameters via its Maven/Gradle plugin โ€” so records "just work" in a standard Boot project. Outside Boot, register it explicitly:

mapper.registerModule(new ParameterNamesModule());

Date and Time โ€” The Most Common Production Gotcha

// Without JavaTimeModule: LocalDateTime throws InvalidDefinitionException at runtime
// With JavaTimeModule + WRITE_DATES_AS_TIMESTAMPS disabled: ISO-8601 strings

// Maven dependency (not pulled in by spring-boot-starter-web automatically in all versions)
<!-- pom.xml -->
<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
    <!-- version managed by Spring Boot parent -->
</dependency>
// Register the module โ€” or set it in application.properties (Spring Boot)
ObjectMapper mapper = new ObjectMapper()
    .registerModule(new JavaTimeModule())
    .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
# application.properties โ€” the Spring Boot way: no Java config needed
spring.jackson.serialization.write-dates-as-timestamps=false
spring.jackson.time-zone=UTC
TypeWithout moduleWith module (ISO-8601)
LocalDateException"2024-01-15"
LocalDateTimeException"2024-01-15T10:30:00"
ZonedDateTimeException"2024-01-15T10:30:00+01:00"
InstantException"2024-01-15T09:30:00Z"
WRITE_DATES_AS_TIMESTAMPS=true (the default) sends epoch milliseconds

Without disabling this feature, Jackson serialises LocalDateTime.of(2024, 1, 15, 10, 30) as the array [2024,1,15,10,30] or a numeric epoch โ€” not the human-readable ISO-8601 string any frontend or external API expects. This is one of the most common "why is the date showing as a number" bugs in new Spring projects.

Spring Boot Auto-Configuration โ€” What Boot Does by Default

Spring Boot auto-configures a single ObjectMapper bean via JacksonAutoConfiguration. Knowing what it sets by default tells you what you don't need to configure manually โ€” and what to override when the default is wrong for your use case.

FeatureBoot defaultWhy
FAIL_ON_UNKNOWN_PROPERTIES false Additive API versioning โ€” clients that send unknown fields don't break
WRITE_DATES_AS_TIMESTAMPS true (pre-Boot 3.x) / configurable Always set explicitly via properties โ€” don't rely on the default
MapperFeature.DEFAULT_VIEW_INCLUSION false Fields without @JsonView are excluded when a view is active
ParameterNamesModule Registered Records and constructor injection work without @JsonCreator
JavaTimeModule Registered if on classpath Auto-detects jackson-datatype-jsr310
# The cleanest way to customise Boot's ObjectMapper: application.properties
# Avoids subclassing or replacing the auto-configured bean entirely
spring.jackson.serialization.write-dates-as-timestamps=false
spring.jackson.deserialization.fail-on-unknown-properties=false
spring.jackson.default-property-inclusion=non_null
spring.jackson.time-zone=UTC
spring.jackson.property-naming-strategy=SNAKE_CASE     # camelCase โ†’ snake_case globally
Customising Boot's ObjectMapper without replacing it

If you declare a @Bean of type ObjectMapper, Boot's auto-configuration backs off entirely โ€” you own the whole configuration. A safer alternative is Jackson2ObjectMapperBuilderCustomizer: it lets you add modules, set features, or change naming strategy on top of Boot's defaults rather than replacing them, which means Boot's own modules (ParameterNames, JavaTime) stay registered:

@Bean
public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
    return builder -> builder
        .modules(new JavaTimeModule())
        .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
        .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
}

Custom Serializers and Deserializers

Use custom serializers when Jackson's default output for a type is wrong for your API contract โ€” sensitive data masking, legacy format compatibility, or types Jackson can't handle automatically (e.g. third-party value objects).

// Custom serializer โ€” masks a credit card number to show only last 4 digits
public class MaskedCardSerializer extends JsonSerializer<String> {
    @Override
    public void serialize(String value, JsonGenerator gen, SerializerProvider provider)
            throws IOException {
        if (value != null && value.length() > 4) {
            gen.writeString("****-****-****-" + value.substring(value.length() - 4));
        } else {
            gen.writeString(value);
        }
    }
}

// Attach to a specific field via annotation
public class PaymentDto {
    @JsonSerialize(using = MaskedCardSerializer.class)
    private String cardNumber;
}
// Custom deserializer โ€” parses a non-standard date format from a legacy API
public class LegacyDateDeserializer extends JsonDeserializer<LocalDate> {
    private static final DateTimeFormatter FMT =
        DateTimeFormatter.ofPattern("dd/MM/yyyy");

    @Override
    public LocalDate deserialize(JsonParser p, DeserializationContext ctx)
            throws IOException {
        return LocalDate.parse(p.getText(), FMT);
    }
}

public record LegacyOrderDto(
    Long id,
    @JsonDeserialize(using = LegacyDateDeserializer.class) LocalDate orderDate
) {}
// Register serializers globally on the mapper (instead of per-field annotations)
SimpleModule module = new SimpleModule();
module.addSerializer(String.class, new MaskedCardSerializer());
mapper.registerModule(module);

@JsonView โ€” Different Fields for Different Contexts

@JsonView lets you define named "views" and annotate fields with the views in which they should appear. A public API endpoint and an admin endpoint can return the same object but with different fields โ€” without creating two separate DTOs.

// Define view marker interfaces (no methods needed โ€” they're just type tokens)
public class Views {
    public interface Public {}
    public interface Admin extends Public {}  // Admin extends Public โ€” inherits Public fields
}

public class UserDto {
    @JsonView(Views.Public.class)
    private Long id;

    @JsonView(Views.Public.class)
    private String name;

    @JsonView(Views.Admin.class)  // only visible in Admin view
    private String email;

    @JsonView(Views.Admin.class)  // only visible in Admin view
    private LocalDateTime lastLogin;
}

// In a Spring MVC controller โ€” activate the view per endpoint
@GetMapping("/api/users/{id}")
@JsonView(Views.Public.class)         // returns: {id, name}
public UserDto getPublic(@PathVariable Long id) { ... }

@GetMapping("/admin/users/{id}")
@JsonView(Views.Admin.class)          // returns: {id, name, email, lastLogin}
public UserDto getAdmin(@PathVariable Long id) { ... }
@JsonView vs separate DTOs โ€” when to use each

@JsonView is the right tool when two endpoints return the same underlying data with different visibility โ€” same service call, same object, different serialisation. Use separate DTOs when the data itself differs โ€” a list view that calls a different query than the detail view, or an admin view that joins additional tables. Overusing @JsonView on a complex object with many views produces an unmaintainable annotation forest.

Jackson in Tests

// In @WebMvcTest โ€” ObjectMapper is auto-configured and injectable
@WebMvcTest(UserController.class)
class UserControllerTest {
    @Autowired private MockMvc mockMvc;
    @Autowired private ObjectMapper objectMapper;  // same instance as the app uses

    @Test
    void createUser_serialisesCorrectly() throws Exception {
        var request = new CreateUserRequest("Ana", "ana@example.com", "pass1234");

        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))  // use the injected mapper
            .andExpect(status().isCreated());
    }
}

// Pure unit test โ€” no Spring context needed for serialisation logic
class UserDtoSerializationTest {
    private final ObjectMapper mapper = new ObjectMapper()
        .registerModule(new JavaTimeModule())
        .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

    @Test
    void passwordHash_isNeverSerialised() throws Exception {
        var user = new UserDto(1L, "Ana", "$2a$10$abc...");
        String json = mapper.writeValueAsString(user);

        assertThat(json).doesNotContain("passwordHash");
        assertThat(json).doesNotContain("$2a$");  // assert the actual hash value isn't there either
    }

    @Test
    void dates_serialiseAsIso8601() throws Exception {
        var dto = new EventDto(LocalDateTime.of(2024, 1, 15, 10, 30));
        String json = mapper.writeValueAsString(dto);

        assertThat(json).contains("\"2024-01-15T10:30:00\"");  // ISO-8601, not an array or epoch
    }
}
Test what Jackson does, not just what your controller does

A pure unit test of serialisation (no Spring context, no MockMvc) runs in milliseconds and catches the most common Jackson bugs: missing modules, wrong date format, fields that should be hidden appearing in output. These bugs are typically only caught by manual API testing or in production when a client complains about the response shape โ€” a trivial unit test catches them at build time.

Interview Questions

๐ŸŽ“ Junior level

Q: What is Jackson and what is ObjectMapper?
Jackson is the de-facto Java library for JSON processing. ObjectMapper is its main entry point โ€” it serialises Java objects to JSON strings and deserialises JSON strings back to Java objects. In Spring Boot it's auto-configured as a singleton bean and used automatically by every @RestController.

Q: What does @JsonIgnore do?
It excludes a field from both serialisation (Java โ†’ JSON) and deserialisation (JSON โ†’ Java). Common use: password hashes, internal audit fields, or any data that must never leave the server boundary. Use @JsonIgnoreProperties at class level to ignore specific fields by name, or to ignore unknown fields from an incoming JSON body.

Q: Why does LocalDateTime sometimes serialise as an array or a number?
Without JavaTimeModule registered and WRITE_DATES_AS_TIMESTAMPS disabled, Jackson falls back to serialising date/time types as their internal representation โ€” an array of fields or an epoch value. Registering JavaTimeModule and disabling timestamps produces ISO-8601 strings instead.

๐Ÿ”ฅ Senior level

Q: Why is creating a new ObjectMapper per request a performance problem, and what's the correct pattern?
ObjectMapper construction is expensive โ€” it scans the classpath, registers modules, and builds internal type caches. ObjectMapper is fully thread-safe after configuration, so the correct pattern is one shared instance (a Spring bean, or a static final field in a utility class). Creating a new instance per request measurably degrades throughput under load.

Q: How does Spring Boot configure Jackson by default, and how do you customise it without replacing the auto-configured bean entirely?
Boot's JacksonAutoConfiguration creates the ObjectMapper bean and registers modules including ParameterNamesModule and JavaTimeModule (if on classpath). Declaring your own @Bean ObjectMapper replaces the auto-configuration entirely, losing those defaults. The safe alternative is Jackson2ObjectMapperBuilderCustomizer โ€” it adds to Boot's defaults instead of replacing them, and is the right choice for enabling features, adding modules, or changing naming strategy.

Q: What does FAIL_ON_UNKNOWN_PROPERTIES=false enable, and when would you want it set to true?
With false (Boot's default), incoming JSON that contains fields the Java class doesn't have is silently ignored โ€” a v1 client sending a v2 payload doesn't fail. This is what makes additive API versioning safe. You'd want true in a strict contract scenario โ€” for example, when deserialising a configuration file where an unknown key almost certainly means a typo, and silently ignoring it would hide the error.

Q: What is @JsonView and when is it preferable to separate DTOs?
@JsonView controls field visibility by context โ€” the same object can serialise differently for a public endpoint vs an admin endpoint without creating two classes. It's the right tool when the underlying data is the same and only the visibility differs. Separate DTOs are correct when the queries, the data shape, or the service calls differ โ€” not just the fields included in the response.