Dates and Nulls with Jackson: java.time, Timestamps, and snake_case
Tame java.time, the null-versus-absent trap, and snake_case so your JSON is portable and predictable.
By default Jackson has no idea what to do with `java.time` types like `Instant`, `LocalDate`, or `OffsetDateTime`. Without help it either throws `InvalidDefinitionException` ("Java 8 date/time type not supported by default") or, worse, serializes them as sprawling reflective objects. The fix is the `jackson-datatype-jsr310` module. In a Spring Boot 3 project you simply add `com.fasterxml.jackson.module:jackson-datatype-jsr310` to your Gradle dependencies; Boot's autoconfiguration discovers it on the classpath via `Jackson2ObjectMapperBuilder` and registers the `JavaTimeModule` for you. If you build an `ObjectMapper` by hand, you must call `registerModule(JavaTimeModule())` (or `findAndRegisterModules()`) yourself, or every date field will fail.
The single most important setting once the module is registered is `WRITE_DATES_AS_TIMESTAMPS`. When this feature is enabled (the raw Jackson default), an `Instant` is written as a numeric epoch value like `1719331200.000000000` and a `LocalDateTime` as an array `[2026,6,26,10,0]` โ compact but hostile to humans and most front ends. When you disable it, `JavaTimeModule` falls back to ISO-8601 strings: `Instant` becomes `"2026-06-26T10:00:00Z"` and `LocalDate` becomes `"2026-06-26"`. Spring Boot is opinionated here and disables the feature for you by default, which is why Boot APIs emit readable ISO strings out of the box. If you ever construct your own mapper, disable it explicitly with `disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)`.
Choosing the right type matters as much as the format. Use `Instant` (or `OffsetDateTime`) for an exact moment on the global timeline โ created-at fields, event times, anything you store in UTC. Use `LocalDate` for a calendar day with no time zone, such as a birthday or an invoice date, and `LocalDateTime` only when a wall-clock time genuinely has no zone attached. A common bug is using `LocalDateTime` for timestamps that cross time zones: it silently drops the offset and your "10:00" means different real instants for different users. Prefer `Instant` at the boundary and convert to a zone only for display.
Null handling is where teams quietly ship bugs. There is a crucial difference between a field that is explicitly `null` in the JSON and a field that is simply absent. By default Jackson serializes a Kotlin nullable property holding `null` as `"field": null`, and on deserialization both an absent key and an explicit `null` map to the same Kotlin `null`. That ambiguity is fine for many APIs but disastrous for PATCH endpoints, where `null` means "clear this value" and absent means "leave it untouched." If you cannot tell those apart, you cannot implement a correct partial update.
You control output nulls with `@JsonInclude`. Annotate a class or property with `@JsonInclude(JsonInclude.Include.NON_NULL)` to omit null fields entirely from the JSON, or set it globally via `objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL)` (in Boot, `spring.jackson.default-property-inclusion=non_null`). `NON_ABSENT` additionally skips empty `Optional` values, and `NON_EMPTY` drops empty strings and collections too. To distinguish null from absent on input, model the field as `JsonNullable<T>` (from the `org.openapitools:jackson-databind-nullable` module): an absent key leaves it `undefined`, while an explicit `null` is captured as a present-but-null value, giving PATCH the three-state information it needs.
Naming strategy is the last common friction point. Kotlin and JVM conventions use `camelCase`, but many APIs, mobile clients, and Python/Ruby consumers expect `snake_case`. Rather than littering every property with `@JsonProperty("created_at")`, set a global `PropertyNamingStrategies.SNAKE_CASE` so `createdAt` serializes as `created_at` and deserializes back automatically. In Spring Boot this is one line of configuration: `spring.jackson.property-naming-strategy=SNAKE_CASE`. The `PropertyNamingStrategies` class (note the plural โ the older singular `PropertyNamingStrategy` constants are deprecated) also offers `KEBAB_CASE`, `LOWER_CASE`, and `UPPER_CAMEL_CASE` for other conventions.
Kotlin adds one more piece: the `jackson-module-kotlin` module. It teaches Jackson about primary-constructor parameters, default values, and true non-nullability so data classes deserialize cleanly without a no-arg constructor. Spring Boot 3 includes and auto-registers it whenever Kotlin is detected, so a `data class` with `val createdAt: Instant` and `val deletedAt: Instant?` just works โ the non-null field is enforced (missing JSON throws a `MismatchedInputException`; older versions raised the now-deprecated `MissingKotlinParameterException`) while the nullable field tolerates absence. Combined with the Kotlin module, your naming, date, and null settings apply uniformly across every data class.
Put it together and a single configured `ObjectMapper` gives you a clean contract: ISO-8601 dates via `jsr310` with timestamps disabled, snake_case keys via `PropertyNamingStrategies`, null fields omitted via `@JsonInclude`, and `JsonNullable` reserved for the PATCH endpoints that genuinely need tri-state semantics. Configure these once at the application level instead of annotating every DTO โ your JSON stays consistent, your front end stops special-casing fields, and your partial updates finally behave the way clients expect.
import com.fasterxml.jackson.annotation.JsonIncludeimport com.fasterxml.jackson.databind.PropertyNamingStrategiesimport com.fasterxml.jackson.databind.SerializationFeatureimport com.fasterxml.jackson.databind.json.JsonMapperimport com.fasterxml.jackson.datatype.jsr310.JavaTimeModuleimport com.fasterxml.jackson.module.kotlin.kotlinModuleval mapper = JsonMapper.builder().addModule(JavaTimeModule()) // teaches Jackson about java.time.addModule(kotlinModule()) // data classes, default values, nullability.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) // -> ISO-8601 strings.serializationInclusion(JsonInclude.Include.NON_NULL) // drop null fields.propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE).build()
Building an ObjectMapper by hand: register jsr310 + Kotlin modules, emit ISO dates instead of timestamps, omit nulls, and use snake_case globally. (Spring Boot does most of this for you via application.yml.)
import com.fasterxml.jackson.annotation.JsonIncludeimport java.time.Instantimport java.time.LocalDate@JsonInclude(JsonInclude.Include.NON_NULL)data class UserDto(val id: Long,val displayName: String,val createdAt: Instant, // "created_at": "2026-06-26T10:00:00Z"val birthDate: LocalDate, // "birth_date": "1995-04-12"val deletedAt: Instant? = null // omitted entirely when null)// Serialized with the mapper above:// { "id": 7, "display_name": "Ada",// "created_at": "2026-06-26T10:00:00Z", "birth_date": "1995-04-12" }
A typical DTO. createdAt is required (an exact UTC moment), birthDate is a calendar day, deletedAt is nullable. With NON_NULL, a null deletedAt disappears from the output JSON.
import org.openapitools.jackson.nullable.JsonNullableimport java.time.LocalDate// Only the keys the client actually sent are 'present'.data class UserPatch(val displayName: JsonNullable<String> = JsonNullable.undefined(),val birthDate: JsonNullable<LocalDate> = JsonNullable.undefined(),)data class UserState(val displayName: String, val birthDate: LocalDate?)fun applyPatch(current: UserState, patch: UserPatch): UserState = current.copy(// absent -> keep current; present -> overwrite (an explicit null clears it)displayName = if (patch.displayName.isPresent) patch.displayName.get() else current.displayName,birthDate = if (patch.birthDate.isPresent) patch.birthDate.get() else current.birthDate,)
The null-vs-absent problem for PATCH. JsonNullable distinguishes 'key was sent as null' (clear it) from 'key was absent' (leave untouched) โ something a plain LocalDate? cannot express. birthDate is nullable here, so an explicit null genuinely clears it.
sealed interface Field<out T> {object Absent : Field<Nothing> // key not sent -> leave untoucheddata class Present<T>(val value: T?) : Field<T> // key sent (value may be null)}fun <T> resolve(current: T?, incoming: Field<T>): T? = when (incoming) {is Field.Absent -> current // keep existingis Field.Present -> incoming.value // overwrite (possibly with null)}fun main() {val current = "Ada"println(resolve(current, Field.Absent)) // Ada (untouched)println(resolve(current, Field.Present("Grace"))) // Grace (replaced)println(resolve(current, Field.Present(null))) // null (cleared)}
Pure-Kotlin demo of the conceptual difference between an explicit null and an absent value, using a sealed three-state type. No Jackson needed โ this runs on the stdlib.
Arena IDE๐ง Check your understanding
0/1 ยท 0/1 answered1. In a Spring Boot 3 + Kotlin API, your endpoint returns an Instant as the numeric value 1719331200.000000000 instead of an ISO-8601 string. What is the most likely cause?