Jackson Annotations in Kotlin: Shaping Your JSON
Master Jackson's core annotations and the one Kotlin use-site target gotcha that silently breaks your serialization.
Jackson is the JSON engine that Spring Boot wires up by default, and most of the time it just works: a Kotlin data class becomes a JSON object and back again with zero configuration. But real APIs rarely match your domain model perfectly. The wire format uses snake_case while your code uses camelCase, some fields must never leave the server, and external payloads send the same value under three different names across API versions. Jackson's annotations are how you bridge that gap declaratively, keeping your data classes clean while controlling exactly what the JSON looks like.
The workhorse is @JsonProperty, which renames a field. If the client sends "first_name" but your property is firstName, annotating it with @JsonProperty("first_name") maps the two. The same annotation controls the output name during serialization, so a single declaration handles both directions. You can also mark a property required = true so Jackson throws if it is missing from the input, which is useful for fail-fast validation at the deserialization boundary before your business logic ever runs.
@JsonIgnore removes a property from JSON entirely. The classic use is a password hash or an internal audit flag that should never be serialized into a response. Be careful: @JsonIgnore is bidirectional by default, so an ignored field is also skipped during deserialization. If you want a write-only field (accepted on input, hidden on output) or a read-only field, reach for @JsonProperty(access = Access.WRITE_ONLY) or READ_ONLY instead, which give you per-direction control that a blunt @JsonIgnore cannot.
When the same logical value arrives under different keys, @JsonAlias accepts a list of fallback names during deserialization while keeping a single canonical output name from @JsonProperty. This is perfect for versioned APIs: @JsonProperty("userId") @JsonAlias("user_id", "uid") reads any of the three but always writes "userId". For constructing objects, @JsonCreator tells Jackson which constructor or factory method to use; with Kotlin data classes the primary constructor is usually picked up automatically when you register the Kotlin module, but @JsonCreator becomes essential for custom factory functions, enums parsed from a string, or value classes wrapping a single field.
@JsonInclude(JsonInclude.Include.NON_NULL) trims null fields from the output so your responses stay lean and your clients are not forced to handle explicit nulls. You can apply it on a single property, on a whole class, or globally via ObjectMapper configuration. Related values like NON_EMPTY (drops empty strings and collections) and NON_ABSENT (understands Kotlin nullables and Optional) let you tune exactly what counts as omittable. This pairs naturally with Kotlin's nullable types: a String? that is null simply disappears from the JSON.
Now the critical Kotlin gotcha. When you annotate a property in a Kotlin class, the annotation could legally land on several places: the backing field, the getter, the setter, the constructor parameter, or others. Kotlin calls these use-site targets, written as @field:, @get:, @param:, and so on. If you write @JsonProperty("name") with no target, Kotlin applies its default precedence (param, then property, then field), and most of the time Jackson finds it. But the moment you mix custom getters, computed properties, or interop with Java-style accessors, an untargeted annotation can attach to the wrong element and Jackson silently ignores it, producing the unrenamed field name with no error at all.
The safe rule: be explicit. Use @field:JsonProperty("first_name") when you want the annotation on the underlying field, which is the most reliable choice for plain data-class properties because Jackson's Kotlin module reads field metadata. Use @get:JsonProperty("fullName") for computed or derived properties that have a getter but no real backing field, such as val fullName get() = first + " " + last, since there is no field to annotate there. Mismatching these is the number-one cause of mysterious "my @JsonProperty does nothing" bugs in Kotlin Spring projects.
In practice, register the Jackson Kotlin module (jackson-module-kotlin, which Spring Boot includes automatically when it detects Kotlin) so the data-class constructor is understood, prefer @field: targets for declared properties and @get: for computed ones, and lean on @JsonInclude for clean payloads. With these few annotations applied deliberately, you get precise, version-tolerant JSON contracts without polluting your domain model or writing a single custom serializer.
import com.fasterxml.jackson.annotation.*@JsonInclude(JsonInclude.Include.NON_NULL)data class UserDto(// Reads "user_id", but also accepts the legacy "uid" from older clients@field:JsonProperty("user_id")@field:JsonAlias("uid", "id")val userId: Long,@field:JsonProperty("first_name")val firstName: String,// Accepted on input, never serialized back out@field:JsonProperty(access = JsonProperty.Access.WRITE_ONLY)val password: String,// Internal flag: completely hidden from JSON, both directions@field:JsonIgnoreval internalScore: Int = 0,// Null disappears from output thanks to @JsonInclude(NON_NULL)val nickname: String? = null,)
Core annotations on a data class: rename, ignore, alias, and trim nulls. Note the explicit @field: use-site targets so Jackson reliably picks them up.
import com.fasterxml.jackson.annotation.JsonPropertydata class Person(@field:JsonProperty("first_name")val first: String,@field:JsonProperty("last_name")val last: String,) {// No backing field exists here, so @field: would attach to nothing.// Use @get: so Jackson sees the getter that produces this value.@get:JsonProperty("full_name")val fullName: Stringget() = "$first $last"}// Serializes to: {"first_name":"Ada","last_name":"Lovelace","full_name":"Ada Lovelace"}
The Kotlin gotcha: @field: for backing fields vs @get: for computed properties. Annotating the wrong target makes Jackson silently ignore your rename.
import com.fasterxml.jackson.annotation.JsonCreatorimport com.fasterxml.jackson.annotation.JsonValueimport com.fasterxml.jackson.databind.ObjectMapperimport com.fasterxml.jackson.module.kotlin.registerKotlinModule@JvmInlinevalue class Email private constructor(@get:JsonValue val raw: String) {companion object {@JsonCreator@JvmStaticfun of(value: String): Email {require("@" in value) { "Invalid email: $value" }return Email(value.lowercase())}}}data class Account(val email: Email)val mapper = ObjectMapper().registerKotlinModule()// reads {"email":"Ada@Example.com"} -> Account(email=Email(ada@example.com))
@JsonCreator with a factory function, ideal for value classes or enums parsed from a single scalar. Register the Kotlin module so constructors are understood.
🧠Check your understanding
0/1 · 0/1 answered1. In a Kotlin data class, you add @JsonProperty("full_name") (with no use-site target) to a computed property that has only a custom getter and no backing field. Jackson ignores the rename. What is the most likely fix?