Jackson Mixins, Annotations & Introspection in Java
Jackson decides how a class looks in JSON by reading annotations on its fields, getters and constructors. Mix-ins let you attach the same annotations to classes you cannot edit, and views let one class produce several JSON shapes. This page explains how Jackson collects annotations, runs the most useful ones, and shows the traps in ignoring properties, mix-in matching and polymorphic types.
Contents
How Jackson reads annotations The most useful annotations Changing the shape: value types, flattening and open-ended objects Mix-ins for classes you cannot change How mix-ins are matched, and the silent typo Polymorphic types Views: one class, several JSON shapes Choosing between annotations, mix-ins, views and serializers
How Jackson reads annotations
When a mapper first meets a class, it collects the class's properties. Each property gathers every member that refers to it: a field namegetName()setName()@JsonPropertyAnnotationIntrospectorcom.fasterxml.jackson.annotation
A mix-in plugs into the same step. When you register addMixIn(Target.class, MixIn.class)
| Job | Annotations | Notes |
|---|---|---|
| Names and order | @JsonProperty@JsonAlias@JsonPropertyOrder | @JsonAlias |
| Leaving properties out | @JsonIgnore@JsonIgnoreProperties@JsonIncludeProperties@JsonInclude | @JsonIncludeProperties |
| Formats and access | @JsonFormat@JsonProperty(access = ...) | Read-only and write-only properties |
| Shape changes | @JsonValue@JsonCreator@JsonUnwrapped@JsonAnyGetter@JsonAnySetter | Single values, flattening, open-ended properties |
| Polymorphism | @JsonTypeInfo@JsonSubTypes | Reading the right subclass back |
| Several shapes per class | @JsonView | Selected per call with writerWithView |
| Grouping | @JacksonAnnotationsInside | Your own annotation that stands for several Jackson ones |
The examples use Jackson 2.21 on JDK 21. The annotations are the same in Jackson 3, which still uses the com.fasterxml.jackson.annotation
The most useful annotations
The Customer
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.time.LocalDate;
import java.util.List;
public class Annotations {
@JsonPropertyOrder({"id", "fullName"})
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
record Customer(
long id,
@JsonProperty("full_name") @JsonAlias({"fullName", "name"}) String fullName,
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "dd.MM.yyyy") LocalDate born,
@JsonIgnore String passwordHash,
List<String> tags) {
}
public static void main(String[] args) throws Exception {
ObjectMapper mapper = JsonMapper.builder().addModule(new JavaTimeModule()).build();
Customer c = new Customer(7, "Ana Lima", LocalDate.of(1990, 5, 4), "$2a$10$x", List.of());
System.out.println("written: " + mapper.writeValueAsString(c));
// Reading accepts the old field names listed in @JsonAlias and skips unknown fields
String oldClient = "{\"id\":8,\"name\":\"Ben Cruz\",\"born\":\"12.11.1985\","
+ "\"passwordHash\":\"leaked?\",\"legacyFlag\":true}";
Customer read = mapper.readValue(oldClient, Customer.class);
System.out.println("read: " + read);
}
}
Output:
written: {"id":7,"full_name":"Ana Lima","born":"04.05.1990"}
read: Customer[id=8, fullName=Ben Cruz, born=1985-11-12, passwordHash=null, tags=null]
| Annotation | Effect in the output |
|---|---|
@JsonProperty("full_name") | The field is written as full_name |
@JsonAlias({"fullName", "name"}) | Reading also accepts the old names; the old client's name |
@JsonFormat(pattern = "dd.MM.yyyy") | The date is written and read as 04.05.1990 |
@JsonIgnore | passwordHash |
@JsonInclude(NON_EMPTY) | The empty tagsNON_NULL |
@JsonIgnoreProperties(ignoreUnknown = true) | legacyFlag |
@JsonPropertyOrder | idfull_name |
@JsonIgnore
Changing the shape: value types, flattening and open-ended objects
Some annotations change the structure of the JSON rather than single property names. The program reads a user whose email arrives with spaces and capitals, whose address fields sit at the top level, and whose input includes an idtoken
import com.fasterxml.jackson.annotation.JacksonAnnotationsInside;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIncludeProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import com.fasterxml.jackson.annotation.JsonValue;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.LinkedHashMap;
import java.util.Map;
public class MoreAnnotations {
// @JsonValue / @JsonCreator: a value type written as a single JSON value
enum Level {
LOW("low"), HIGH("high");
private final String code;
Level(String code) {
this.code = code;
}
@JsonValue
String code() {
return code;
}
}
record Email(@JsonValue String value) {
@JsonCreator
static Email of(String value) {
return new Email(value.trim().toLowerCase());
}
}
record Address(String city, String zip) {
}
// A combined annotation: one name for a group of Jackson annotations
@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside
@JsonIgnore
@interface Secret {
}
public static class User {
public String name;
public Email email;
public Level level;
@JsonUnwrapped
public Address address; // its fields move up one level
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
public long id = 99; // written, never read from JSON
@Secret
public String token = "t-123";
}
// @JsonAnySetter / @JsonAnyGetter: keep properties the class does not declare
public static class Flexible {
public String name;
@Secret
public String token;
private final Map<String, Object> extra = new LinkedHashMap<>();
@JsonAnySetter
void put(String key, Object value) {
extra.put(key, value);
}
@JsonAnyGetter
Map<String, Object> extra() {
return extra;
}
}
@JsonIncludeProperties({"name", "level"}) // allow-list instead of deny-list
public static class UserSummary extends User {
}
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
String in = """
{"name":"Ana","email":" Ana@Example.COM ","level":"high",
"city":"Oslo","zip":"0150","id":1,"token":"stolen"}""";
User u = mapper.readValue(in, User.class);
System.out.println("email: " + u.email + ", level: " + u.level);
System.out.println("address: " + u.address + ", id: " + u.id + ", token: " + u.token);
System.out.println("written: " + mapper.writeValueAsString(u));
Flexible f = mapper.readValue("{\"name\":\"Ben\",\"plan\":\"pro\",\"seats\":5,"
+ "\"token\":\"stolen\"}", Flexible.class);
System.out.println("any-setter: token=" + f.token + ", extra=" + f.extra());
System.out.println("any-getter: " + mapper.writeValueAsString(f));
UserSummary s = mapper.readValue(in, UserSummary.class);
System.out.println("summary: " + mapper.writeValueAsString(s));
}
}
Output:
email: Email[value=ana@example.com], level: HIGH
address: Address[city=Oslo, zip=0150], id: 99, token: t-123
written: {"name":"Ana","email":"ana@example.com","level":"high","city":"Oslo","zip":"0150","id":99}
any-setter: token=null, extra={plan=pro, seats=5, token=stolen}
any-getter: {"name":"Ben","plan":"pro","seats":5,"token":"stolen"}
summary: {"name":"Ana","level":"high"}
makes a type serialize as one value: the enum@JsonValue is written as its codeHIGH , and the"high" record as a plain string. The staticEmail factory is used for reading, so@JsonCreator was normalized to" Ana@Example.COM " . For enums, Jackson also uses theana@example.com method to match input, so@JsonValue was read as"high" .HIGH flattened the@JsonUnwrapped : itsAddress andcity appear next tozip in the JSON and were gathered back into anname when reading.Address means "only ever written": the client's@JsonProperty(access = READ_ONLY) was ignored and the field kept 99, but 99 was written out. The custom"id":1 annotation carries@Secret and@JacksonAnnotationsInside , so the token was neither read nor written.@JsonIgnore- The trap:
receives every property that is not bound to a field, and that includes ignored ones. In@JsonAnySetter , theFlexible field stayed null, yet@Secret landed in the extra map, andtoken=stolen wrote it straight back out. Filter the any-setter's keys yourself when ignored properties must never pass through.@JsonAnyGetter is an allow-list:@JsonIncludeProperties({"name", "level"}) wrote only those two properties. An allow-list is safer than a list of ignored names, because a property added to the class later stays hidden until someone adds it.UserSummary
Mix-ins for classes you cannot change
A mix-in is a separate class or interface carrying Jackson annotations, registered with addMixIn(target, mixIn)
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import java.util.List;
public class MixIns {
// Pretend these come from a library: we cannot add annotations to them
static class Account {
private final String owner;
private final String apiKey;
Account(String owner, String apiKey) {
this.owner = owner;
this.apiKey = apiKey;
}
public String getOwner() {
return owner;
}
public String getApiKey() {
return apiKey;
}
}
sealed interface Shape permits Circle, Square {
}
record Circle(double radius) implements Shape {
}
record Square(double side) implements Shape {
}
// Mix-ins: annotations written on a separate type and applied to the target class
abstract static class AccountMixIn {
@JsonProperty("account_owner")
abstract String getOwner();
@JsonIgnore
abstract String getApiKey();
}
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({@JsonSubTypes.Type(value = Circle.class, name = "circle"),
@JsonSubTypes.Type(value = Square.class, name = "square")})
interface ShapeMixIn {
}
record Drawing(List<Shape> shapes) {
}
public static void main(String[] args) throws Exception {
ObjectMapper plain = new ObjectMapper();
Account acc = new Account("ana", "sk_live_123");
System.out.println("without mix-in: " + plain.writeValueAsString(acc));
ObjectMapper mapper = JsonMapper.builder()
.addMixIn(Account.class, AccountMixIn.class)
.addMixIn(Shape.class, ShapeMixIn.class)
.build();
System.out.println("with mix-in: " + mapper.writeValueAsString(acc));
// Polymorphism added by a mix-in: a "type" property selects the subclass
String json = mapper.writeValueAsString(new Drawing(List.of(new Circle(1.5),
new Square(2))));
System.out.println("shapes written: " + json);
Drawing back = mapper.readValue(json, Drawing.class);
System.out.println("shapes read: " + back);
try {
plain.readValue(json, Drawing.class);
} catch (Exception e) {
System.out.println("same JSON without the mix-in: " + e.getClass().getSimpleName());
}
}
}
Output:
without mix-in: {"owner":"ana","apiKey":"sk_live_123"}
with mix-in: {"account_owner":"ana"}
shapes written: {"shapes":[{"type":"circle","radius":1.5},{"type":"square","side":2.0}]}
shapes read: Drawing[shapes=[Circle[radius=1.5], Square[side=2.0]]]
same JSON without the mix-in: InvalidDefinitionException
- Without the mix-in, the API key went straight into the JSON. With it, the owner is renamed to
and the key is gone, without touching theaccount_owner class.Account and@JsonTypeInfo make Jackson write a@JsonSubTypes property and use it when reading, so the list came back as atype and aCircle . Without that information, Jackson cannot instantiate the interface and throwsSquare .InvalidDefinitionException
How mix-ins are matched, and the silent typo
Mix-ins are matched by member name and signature, and nothing checks that a match exists. The program uses an immutable MoneyisZero()
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
public class MixInRules {
// "Library" classes: immutable, no default constructor, no annotations
public static class Money {
private final long cents;
private final String currency;
public Money(long cents, String currency) {
this.cents = cents;
this.currency = currency;
}
public long getCents() {
return cents;
}
public String getCurrency() {
return currency;
}
public boolean isZero() {
return cents == 0;
}
@Override
public String toString() {
return cents + " " + currency;
}
}
public static class Price extends Money {
public Price(long cents, String currency) {
super(cents, currency);
}
}
// Mix-in with a creator: describes the constructor of Money
abstract static class MoneyMixIn {
@JsonCreator
MoneyMixIn(@JsonProperty("cents") long cents, @JsonProperty("currency") String c) {
}
@JsonIgnore
abstract boolean isZero();
}
// A typo: there is no method isEmpty() in Money, so this does nothing
abstract static class TypoMixIn {
@JsonIgnore
abstract boolean isEmpty();
}
public static void main(String[] args) throws Exception {
Money m = new Money(1999, "EUR");
System.out.println("plain: " + new ObjectMapper().writeValueAsString(m));
ObjectMapper typo = JsonMapper.builder().addMixIn(Money.class, TypoMixIn.class).build();
System.out.println("typo mix-in: " + typo.writeValueAsString(m));
ObjectMapper mapper = JsonMapper.builder().addMixIn(Money.class, MoneyMixIn.class).build();
String json = mapper.writeValueAsString(m);
System.out.println("mix-in: " + json + " -> read back " + mapper.readValue(json,
Money.class));
// A mix-in for a class also applies to its subclasses' inherited members
System.out.println("subclass: " + mapper.writeValueAsString(new Price(500, "USD")));
// The mix-in is only on this mapper; other mappers see the class unchanged
System.out.println("has mix-in: " + mapper.findMixInClassFor(Money.class)
.getSimpleName() + ", other mapper: "
+ new ObjectMapper().findMixInClassFor(Money.class));
}
}
Output:
plain: {"cents":1999,"currency":"EUR","zero":false}
typo mix-in: {"cents":1999,"currency":"EUR","zero":false}
mix-in: {"cents":1999,"currency":"EUR"} -> read back 1999 EUR
subclass: {"cents":500,"currency":"USD"}
has mix-in: MoneyMixIn, other mapper: null
- A plain mapper writes
, because"zero":false looks like a boolean getter.isZero() annotatesTypoMixIn , which does not exist inisEmpty() . Jackson accepted it without a warning, andMoney was still written. When a mix-in seems to do nothing, compare its method names and parameter types with the target's.zero declares a constructor with the same parameter types asMoneyMixIn 's and marks itMoney , which lets Jackson call the real constructor when reading. The output shows the round trip to@JsonCreator . The mix-in's constructor and abstract methods are never executed; only their annotations are used.1999 EUR- The mix-in also applied to the subclass
, whose inheritedPrice was hidden too.isZero() confirms that the mix-in belongs to one mapper: a new mapper returnedfindMixInClassFor . Spring Boot can register mix-ins on its shared mapper throughnull classes or a@JsonMixin .Jackson2ObjectMapperBuilder
Polymorphic types
A field declared as an interface can hold different classes, and JSON has no built-in way to say which one. The program writes an event feed with a LoginPurchase
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import java.util.List;
public class Polymorphism {
sealed interface Event permits Login, Purchase {
}
record Login(String user) implements Event {
}
record Purchase(String user, double amount) implements Event {
}
// Four ways to record the type, each as a mix-in on the same interface
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind")
@JsonSubTypes({@JsonSubTypes.Type(value = Login.class, name = "login"),
@JsonSubTypes.Type(value = Purchase.class, name = "purchase")})
interface ByName {
}
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.WRAPPER_OBJECT)
@JsonSubTypes({@JsonSubTypes.Type(value = Login.class, name = "login"),
@JsonSubTypes.Type(value = Purchase.class, name = "purchase")})
interface Wrapped {
}
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)
interface ByClass {
}
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind")
interface NoSubtypes {
}
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind", defaultImpl = Login.class)
@JsonSubTypes({@JsonSubTypes.Type(value = Login.class, name = "login"),
@JsonSubTypes.Type(value = Purchase.class, name = "purchase")})
interface WithDefault {
}
record Feed(List<Event> events) {
}
static ObjectMapper with(Class<?> mixIn) {
return JsonMapper.builder().addMixIn(Event.class, mixIn).build();
}
public static void main(String[] args) throws Exception {
Feed feed = new Feed(List.of(new Login("ana"), new Purchase("ana", 9.5)));
for (Class<?> mixIn : List.of(ByName.class, Wrapped.class, ByClass.class)) {
ObjectMapper m = with(mixIn);
String json = m.writeValueAsString(feed);
System.out.println(mixIn.getSimpleName() + ": " + json);
System.out.println(" read back equal: " + m.readValue(json, Feed.class).equals(feed));
}
// Without @JsonSubTypes, Jackson 2 does not look at the sealed permits list
ObjectMapper none = with(NoSubtypes.class);
String noneJson = none.writeValueAsString(feed);
System.out.println("NoSubtypes writes: " + noneJson);
try {
none.readValue(noneJson, Feed.class);
} catch (Exception e) {
System.out.println("NoSubtypes read: " + e.getClass().getSimpleName());
}
// An unknown type id fails, unless a default implementation is given
String unknown = "{\"events\":[{\"kind\":\"logout\",\"user\":\"ben\"}]}";
try {
with(ByName.class).readValue(unknown, Feed.class);
} catch (Exception e) {
String msg = e.getMessage();
System.out.println("unknown id: " + msg.substring(0, msg.indexOf(" as a subtype")));
}
System.out.println("with default: " + with(WithDefault.class).readValue(unknown,
Feed.class));
}
}
Output:
ByName: {"events":[{"kind":"login","user":"ana"},{"kind":"purchase","user":"ana","amount":9.5}]}
read back equal: true
Wrapped: {"events":[{"login":{"user":"ana"}},{"purchase":{"user":"ana","amount":9.5}}]}
read back equal: true
ByClass: {"events":[{"@class":"Polymorphism$Login","user":"ana"},{"@class":"Polymorphism$Purchase","user":"ana","amount":9.5}]}
read back equal: true
NoSubtypes writes: {"events":[{"kind":"Polymorphism$Login","user":"ana"},{"kind":"Polymorphism$Purchase","user":"ana","amount":9.5}]}
NoSubtypes read: InvalidTypeIdException
unknown id: Could not resolve type id 'logout'
with default: Feed[events=[Login[user=ben]]]
with a property writes a logical name,Id.NAME , inside each object. This is the most common format and the easiest for other languages to produce."kind":"login" wraps each value in an object whose single key is the type,As.WRAPPER_OBJECT , which some APIs and XML-style formats use.{"login":{...}} writes the Java class name,Id.CLASS . It works without a subtype list, but it ties the JSON to your package and class names. Worse, a reader that accepts any class name lets the input choose which class to instantiate, which has caused many remote code execution vulnerabilities in Jackson-based services. Use logical names with an explicit list, or"@class":"Polymorphism$Login" if class names are unavoidable.PolymorphicTypeValidator- Without
, Jackson 2.21 wrote default names (the class names) but could not read its own output:@JsonSubTypes . It does not use a sealed interface'sInvalidTypeIdException list to find subtypes, so list them explicitly.permits - An unknown id such as
fails with "Could not resolve type id".logout made the unknown event fall back to adefaultImpl = Login.class , which keeps old readers working when a newer producer adds event types.Login
Views: one class, several JSON shapes
@JsonViewwriterWithViewnotes
import com.fasterxml.jackson.annotation.JsonView;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
public class Views {
static class PublicView {
}
static class AdminView extends PublicView {
}
record Employee(
@JsonView(PublicView.class) String name,
@JsonView(PublicView.class) String team,
@JsonView(AdminView.class) int salary,
String notes) { // no view
}
public static void main(String[] args) throws Exception {
Employee e = new Employee("Ana", "payments", 82_000, "promotion due");
ObjectMapper mapper = new ObjectMapper();
System.out.println("no view: " + mapper.writeValueAsString(e));
System.out.println("public view: "
+ mapper.writerWithView(PublicView.class).writeValueAsString(e));
System.out.println("admin view: "
+ mapper.writerWithView(AdminView.class).writeValueAsString(e));
// Fields without @JsonView are included in every view unless this is disabled
ObjectMapper strict = JsonMapper.builder()
.disable(MapperFeature.DEFAULT_VIEW_INCLUSION).build();
System.out.println("public view, DEFAULT_VIEW_INCLUSION off: "
+ strict.writerWithView(PublicView.class).writeValueAsString(e));
}
}
Output:
no view: {"name":"Ana","team":"payments","salary":82000,"notes":"promotion due"}
public view: {"name":"Ana","team":"payments","notes":"promotion due"}
admin view: {"name":"Ana","team":"payments","salary":82000,"notes":"promotion due"}
public view, DEFAULT_VIEW_INCLUSION off: {"name":"Ana","team":"payments"}
The public view left out the salary, and the admin view included it. But notes@JsonViewMapperFeature.DEFAULT_VIEW_INCLUSIONreaderWithView
When the shapes differ a lot, separate response records (DTOs) are often clearer than views, because each record shows exactly what is sent.
Choosing between annotations, mix-ins, views and serializers
| Situation | Use |
|---|---|
| You own the class and the JSON is fixed | Annotations on the class |
| The class is from a library or generated code | A mix-in registered on the mapper |
| The same class needs different JSON for different APIs | Mix-ins on separate mappers, or separate DTO records |
| Some callers may see fewer fields | @JsonViewDEFAULT_VIEW_INCLUSION |
| Interface or abstract field types | @JsonTypeInfo(use = Id.NAME)@JsonSubTypes |
| Keeping unknown properties | @JsonAnySetter@JsonAnyGetter |
| A type written as one value | @JsonValue@JsonCreator |
| Logic no annotation can express | A |
For mapper-wide settings such as naming strategies and unknown-property handling, see