Skip to main content

JSON Columns in Hibernate 7: @JdbcTypeCode(SqlTypes.JSON) and Querying JSONB on PostgreSQL

Map, List, record and POJO attributes as JSON columns in Hibernate 7.4: the DDL on PostgreSQL 18 and MySQL 8, native JSONB operators and the question-mark trap, HQL JSON functions, GIN and expression index plans on 50,000 rows, dirty checking, and Jackson 3 vs 2.

You have a table where every row is mostly the same, except for one part that is not: a product catalogue where a lamp has wattage and a chair has seat height, a webhook log where every provider sends a different payload, a settings blob that grows a new key every release. You can model that as columns (and run ALTER TABLE forever), as a pile of key/value rows (and join forever), or as one JSON column. Hibernate 7 can map a Java Map, List, record or plain class to such a column with a single annotation — and then the interesting part starts: what SQL does it emit, how do you query inside the document, how do you index it, does Hibernate notice when you change it, and which JSON library is it quietly using?

This post answers those with things I compiled and ran: a companion module with eleven test classes against a real PostgreSQL 18 and a real MySQL 8.0, 50,000 rows for the index plans, and a probe that boots Hibernate under nine different classpaths. Every console block below is copied from a committed transcript in the module’s output/ folder. Where I could not run something, the post says so.

Versions this was run against. Hibernate ORM 7.4.14.Final (on Maven Central since 2026-10-25; Spring Boot 4.1.2 manages 7.4.13, so the module overrides it), Spring Boot 4.1.2 (2026-10-22) with Spring Data JPA 4.1.2, Jackson 3.1.7 (what Boot 4.1.2 manages; 3.2.4 is the newest on Central), PostgreSQL 18.6 with driver 42.7.14, MySQL 8.0.46 with Connector/J 9.7.0, JDK 25.0.5+8. No Docker in my sandbox: PostgreSQL ran from the zonky embedded-postgres-binaries jar on Maven Central and MySQL from Ubuntu’s mysql-server-core-8.0 package, both started by scripts/start-databases.sh. Resolved versions are in output/00-versions.txt.

One annotation turns a Java object into a JSON column

A JSON column holds a whole document in one cell. To use one from Java, Hibernate has to answer two questions: how do I turn this Java value into JSON text and back, and how do I hand that text to the database driver. The first is the job of a FormatMapper (in practice a Jackson wrapper); the second is a JdbcType chosen by the dialect. @JdbcTypeCode(SqlTypes.JSON) just says “this attribute is JSON”; both parts are then picked for you.

Two independent parts turn a Java value into a JSON columnJava valueMap, List, record,POJO, @EmbeddableFormatMapperJava object ↔ JSON text(Jackson, usually)JdbcTypehands the text tothe driverColumnjsonb (PostgreSQL)json (MySQL)swap it withhibernate.type.json_format_mapperswap the binding with @JdbcType;change only the DDL with @Column(columnDefinition)Hibernate does not parse JSON itself: a FormatMapper serialises, the dialect decides how the text reaches the column.Most JSON surprises (which library, which column type, what comes back) are one of these two boxes behaving differently from what you assumed.

The diagram is worth keeping in your head because every surprise later in this post is one of the two middle boxes behaving differently from what you assumed — a different JSON library than you expected, or a different column type than you expected.

Four Java shapes, each in its own JSON column. The module’s entity: From src/main/java/com/ankurm/jsoncolumns/model/Product.java:

@Entity
@Table(name = "product")
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @JdbcTypeCode(SqlTypes.JSON)
    private Map<String, Object> attributes;

    @JdbcTypeCode(SqlTypes.JSON)
    private List<String> tags;

    @JdbcTypeCode(SqlTypes.JSON)
    private Dimensions dimensions;

    @JdbcTypeCode(SqlTypes.JSON)
    private Specs specs;

The record is stored as a JSON object: From src/main/java/com/ankurm/jsoncolumns/model/Dimensions.java:

public record Dimensions(int widthMm, int heightMm, int depthMm) { }

On PostgreSQL the DDL for one such column is: Captured in output/01-ddl-postgresql.txt:

create table t_default (id bigint generated by default as identity, doc jsonb, primary key (id));

Persisting one Product sends one INSERT with a bind per JSON column; PostgreSQL then holds this: Captured in output/02-mapping-roundtrip.txt:

sql> insert into product (attributes,dimensions,name,specs,tags) values (?,?,?,?,?)
attributes = {"color": "red", "price": 19.90, "ratio": 0.5, "stock": 12, "active": true, "nested": {"k": [1, 2, 3]}, "nothing": null, "bigNumber": 3000000000}
tags = ["lighting", "office"]
dimensions = {"depthMm": 180, "widthMm": 180, "heightMm": 420}
specs = {"material": "steel", "weightKg": 1.8, "certifications": ["CE", "RoHS"]}

Notice what PostgreSQL did to the record: {"depthMm": 180, "widthMm": 180, "heightMm": 420} is not in the order of the record’s fields. The column is jsonb (binary JSON), which parses the document on the way in and stores keys in its own order. Remember that; it comes back in the json versus jsonb section.

A second statement you may have spotted. The transcript for that test also contains an update product set ... that nobody asked for, right after the INSERT. That is not a copy-paste slip; it is the first appearance of a trap that gets its own section (“Does Hibernate notice when you change the JSON in place?”).

Going deeper: the classes behind the annotation

On PostgreSQL, the DDL type for SqlTypes.JSON is jsonb (shown above). Hibernate 7.4.14’s jar contains org.hibernate.dialect.type.PostgreSQLJsonPGObjectJsonbType and its sibling PostgreSQLJsonPGObjectJsonType (I listed the jar). The FormatMapper side is org.hibernate.type.format.jackson.Jackson3JsonFormatMapper, JacksonJsonFormatMapper (Jackson 2) or jakartajson.JsonBJsonFormatMapper. What decides between them is its own section below.

<

Four shapes round-trip, but what comes back is not always what went in

Map, List, record and POJO all round-trip with the same annotation. The only shape that needs care is Map<String, Object>, because JSON has one kind of number and Java has several. The test puts an Integer, a Long, a BigDecimal, a Double, a boolean, a null and a nested map into one map, saves it, and reads it back in a fresh session:

What each value became (one line per key, then the typed record and POJO): Captured in output/02-mapping-roundtrip.txt:

color      -> String       red
price      -> Double       19.9
ratio      -> Double       0.5
stock      -> Integer      12
active     -> Boolean      true
nested     -> LinkedHashMap {k=[1, 2, 3]}
nothing    -> null         null
bigNumber  -> Long         3000000000
dimensions -> Dimensions[widthMm=180, heightMm=420, depthMm=180]
specs      -> steel, 1.8, [CE, RoHS]

The Integer 12 came back as an Integer and the Long 3,000,000,000 as a Long. The BigDecimal("19.90") did not survive: PostgreSQL stored 19.90, but Hibernate handed back a Double 19.9. A nested object comes back as a LinkedHashMap, and a key whose value was null stays present with a null value. If exact decimals matter, put them in a typed record or POJO field of type BigDecimal, not in a free-form map.

One more distinction worth knowing: a Java null is SQL NULL, not a JSON null and not {}: Captured in output/02-mapping-roundtrip.txt:

null-attrs | attributes=SQL NULL | tags=SQL NULL | dimensions=SQL NULL
empty-attrs | attributes={} | tags=[] | dimensions=SQL NULL
Dates inside a free-form Map do not round-trip. A java.time.Instant inside a Map<String, Object> was stored as a number of seconds and read back as a Double: {"at": 1793355330.000000000}, then read back as {at=1.79335533E9} (Double). Inside a record field typed Instant it did come back as an Instant (see the FormatMapper section). Type your documents; use the map only for what is truly open-ended.
Going deeper: the whole transcript

The full output of the round-trip test, including the INSERT, is output/02-mapping-roundtrip.txt; the code is A02_MappingTest.java.

On PostgreSQL you get jsonb, not json — and @JdbcType will not change that

PostgreSQL has two JSON column types. json stores your text exactly as sent. jsonb parses it into a binary form: it does not keep whitespace, duplicate keys or key order, and it is the type the GIN indexes later in this post work on. Hibernate 7.4 on PostgreSQL emits jsonb for SqlTypes.JSON. I tried five ways to ask for one or the other and read the DDL that came out:

From src/test/java/com/ankurm/jsoncolumns/A01_DdlTest.java:

    @Entity(name = "DefaultJson") @Table(name = "t_default")
    static class DefaultJson {
        @Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
        @JdbcTypeCode(SqlTypes.JSON) Map<String, Object> doc;
    }

    @Entity(name = "ColumnDefJson") @Table(name = "t_coldef_json")
    static class ColumnDefJson {
        @Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
        @JdbcTypeCode(SqlTypes.JSON) @Column(columnDefinition = "json") Map<String, Object> doc;
    }

    @Entity(name = "ColumnDefJsonb") @Table(name = "t_coldef_jsonb")
    static class ColumnDefJsonb {
        @Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
        @JdbcTypeCode(SqlTypes.JSON) @Column(columnDefinition = "jsonb") Map<String, Object> doc;
    }

    @Entity(name = "JdbcTypeJson") @Table(name = "t_jdbctype_json")
    static class JdbcTypeJson {
        @Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
        @JdbcType(PostgreSQLJsonPGObjectJsonType.class) Map<String, Object> doc;
    }

    @Entity(name = "JdbcTypeJsonb") @Table(name = "t_jdbctype_jsonb")
    static class JdbcTypeJsonb {
        @Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
        @JdbcType(PostgreSQLJsonPGObjectJsonbType.class) Map<String, Object> doc;
    }

The DDL Hibernate emitted for each variant (JPA script generation, nothing executed): Captured in output/01-ddl-postgresql.txt:

create table t_coldef_json (id bigint generated by default as identity, doc json, primary key (id));
create table t_coldef_jsonb (id bigint generated by default as identity, doc jsonb, primary key (id));
create table t_default (id bigint generated by default as identity, doc jsonb, primary key (id));
create table t_jdbctype_json (id bigint generated by default as identity, doc jsonb, primary key (id));
create table t_jdbctype_jsonb (id bigint generated by default as identity, doc jsonb, primary key (id));
@JdbcType(PostgreSQLJsonPGObjectJsonType.class) alone still creates a jsonb column. It changes how the value is bound, not the column’s declared type: t_jdbctype_json.doc is jsonb in the DDL above and in information_schema. The only variant that produced a real json column was @Column(columnDefinition = "json"). If you want json, say it in the column definition (and think twice, below).

Why you almost always want jsonb: the same text inserted into both types. Captured in output/01-ddl-postgresql.txt:

json | jsonb: {"z": 1,  "a": 2, "a": 3} | {"a": 3, "z": 1}
json = json: ERROR: operator does not exist: json = json
jsonb = jsonb: t

The json column kept the duplicate key "a" twice and the double space; jsonb kept the last "a", dropped the whitespace and reordered. In exchange, json has no equality operator, so even json = json fails. Pick json only if you must reproduce the exact bytes you were sent (a signed webhook body, say).

Going deeper: what I did not test for json columns

I confirmed that a json column written through columnDefinition round-trips through Hibernate (the transcript ends with the read-back lines). I did not run the query features of the later sections against json columns. See output/01-ddl-postgresql.txt.

<

Native JSONB operators work — except the ones that contain a question mark

The most direct way to query inside a document is the database’s own operators in a native query: ->> extracts a field as text, @> asks “does this document contain that fragment”, ? asks “is this key present”. Hibernate passes native SQL through, so most of it just works. I ran a couple of dozen spellings against five sample rows; the ones that matter:

From src/test/java/com/ankurm/jsoncolumns/A06_NativeJsonbTest.java:

                new Case("->> with a named parameter", "select name from product where attributes->>'color' = :c order by id", Map.of("c", "black")),
                new Case("@> containment, parameter with cast(:p as jsonb)", "select name from product where attributes @> cast(:p as jsonb) order by id", Map.of("p", "{\"color\":\"black\"}")),
                new Case("jsonb_exists() function instead of ?", "select name from product where jsonb_exists(attributes, 'color') order by id"),

The result of each (the sql> line is what I sent, the next line is the outcome): Captured in output/06-native-jsonb-operators.txt:

sql> select name from product where attributes->>'color' = :c order by id   -- {c=black}
OK Floor lamp; Office chair
sql> select name from product where attributes @> cast(:p as jsonb) order by id   -- {p={"color":"black"}}
OK Floor lamp; Office chair
sql> select name from product where attributes @> :p::jsonb order by id   -- {p={"color":"black"}}
FAILED IllegalArgumentException: No parameter named ':p' in query with named parameters [p::jsonb]
sql> select name from product where attributes ? 'color' order by id
FAILED QueryParameterException: No argument for ordinal parameter '?1'
sql> select name from product where attributes \? 'color' order by id
FAILED DataException: No value specified for parameter 1.
sql> select name from product where attributes \?\? 'color' order by id
OK Desk lamp; Floor lamp; Office chair; Bookshelf; Monitor arm
sql> select name from product where jsonb_exists(attributes, 'color') order by id
OK Desk lamp; Floor lamp; Office chair; Bookshelf; Monitor arm
A bare ? in a native query is read as a JDBC parameter. attributes ? 'color' fails with No argument for ordinal parameter ‘?1’; escaping one question mark with a backslash gets past Hibernate but then the PostgreSQL driver sees a placeholder (No value specified for parameter 1). What worked was escaping every question mark (\?\? for ?, \?\?| for ?|, @\?\? for @?) — or calling the operator’s function instead: jsonb_exists(attributes, 'color'), jsonb_exists_any(tags, array[..]), jsonb_path_exists(..). Likewise :p::jsonb is read as a parameter named p::jsonb; write cast(:p as jsonb).

One more native-query detail: createNativeQuery(sql) on a jsonb column hands back a plain String of JSON, createNativeQuery(sql, Map.class) gives a HashMap, and createNativeQuery(sql, Product.class) maps the whole entity with its JSON columns intact. All three are in the transcript.

Going deeper: every spelling I tried

Every case, with the exact SQL and the exact outcome, is in output/06-native-jsonb-operators.txt and A06_NativeJsonbTest.java. The predicate operator @@ and the function jsonb_path_exists worked without escaping because neither contains a question mark outside a string literal; Hibernate leaves question marks inside quoted literals alone ('$.stock ? (@ > 5)' was fine).

<

HQL has JSON functions — behind a tech-preview switch

Native SQL ties the query to one database. Hibernate 7 also has portable HQL functions that follow the SQL/JSON standard: json_value, json_query, json_exists, json_object, json_array, json_arrayagg, json_objectagg, json_table, and a family of modifiers (json_set, json_insert, json_replace, json_remove, json_mergepatch, json_array_append, json_array_insert). I ran every one against PostgreSQL 18.6 and MySQL 8.0.46 with the same statements. First, the switch:

With default settings, json_value is refused: Captured in output/04-hql-json-functions-postgresql.txt:

FAILED IllegalArgumentException: Can't use function 'json_value', because tech preview JSON functions are not enabled. To enable, set the 'hibernate.query.hql.json_functions_enabled' setting to 'true'. [select p.name, json_value(p.attributes, '$.color') from Product p where p.id = 1]
The query functions are a tech preview and off by default; json_set ran without it. Set hibernate.query.hql.json_functions_enabled=true (in Spring Boot: spring.jpa.properties.hibernate.query.hql.json_functions_enabled=true) to use json_value, json_query, json_exists, json_object, json_array, the aggregates and json_table. In the same default-settings run, json_exists was refused too, while the UPDATE ... set x = json_set(..) statement ran: the one update-side function I tried, json_set, did not need the switch (I did not try the other modifiers in an UPDATE under default settings). “Tech preview” is Hibernate’s own wording in the error, so expect these to change.

The HQL I ran (extract of the case list): From src/test/java/com/ankurm/jsoncolumns/A04_HqlJsonFunctionsTest.java:

cases.put("json_value, returning integer", "select p.name, json_value(p.attributes, '$.stock' returning integer) from Product p order by p.id");
cases.put("json_value in where", "select p.name from Product p where json_value(p.attributes, '$.color') = 'black' order by p.id");
cases.put("json_exists with filter", "select p.name from Product p where json_exists(p.attributes, '$.stock ? (@ > 5)') order by p.id");
cases.put("json_table, tags counted", "select t.tag, count(*) from Product p join lateral json_table(p.tags, '$[*]' columns (tag String path '$')) t group by t.tag order by t.tag");
cases.put("json_set", "update Product p set p.attributes = json_set(p.attributes, '$.color', 'green') where p.id = 1");

One HQL predicate, json_value(p.attributes, '$.color') = 'black', becomes this on PostgreSQL: Captured in output/04-hql-json-functions-postgresql.txt:

sql> select p1_0.name from product p1_0 where json_value(p1_0.attributes,'$.color' returning jsonb)#>>'{}'='black' order by p1_0.id

and this on MySQL: Captured in output/08-mysql.txt:

sql> select p1_0.name from product p1_0 where json_unquote(nullif(json_extract(p1_0.attributes,'$.color'),cast('null' as json)))='black' order by p1_0.id

The PostgreSQL form is the SQL/JSON JSON_VALUE function; I ran it on 18.6 only and did not test older servers. Results were identical on both databases for most functions. Here is the summary, family by family (every individual statement is in the accordion below):

HQL functionPostgreSQL 18.6MySQL 8.0.46
json_value (string, returning integer, nested path, array index, record column, list column, missing key)worksworks
json_query (sub-document, with wrapper)worksworks
json_exists on a plain pathworksworks
json_exists with a filter, '$.stock ? (@ > 5)'worksFAILED: Invalid JSON path expression. The error is around character position 8.
json_object, json_array, json_arrayagg, json_objectaggworksworks
json_table (one row per array element)worksworks
json_set, json_insert, json_replace, json_remove, json_mergepatch, json_array_insert in UPDATEworksworks
json_array_append on a nested pathworksworks
json_array_append on the root path '$'FAILED: generated SQL has a syntax errorworks
attribute navigation p.attributes.color on a Map columnFAILED: Could not interpret attribute ‘color’ of basic-valued path ‘com.ankurm.jsoncolumns.model.Product(p).attributes’FAILED: same

Two failures worth knowing the fingerprint of. On PostgreSQL json_array_append(p.tags, '$', 'sale') failed with SQLGrammarException: ERROR: syntax error at or near "]": the generated SQL contains array]::text[] where the path array for the root should be (see the transcript). The same call on a nested path works, and on MySQL both work. On MySQL, json_exists with a PostgreSQL-style filter fails with Invalid JSON path expression. The error is around character position 8. — the path language is passed through to the database, so filter expressions are not portable even though the function is.

Going deeper: all statements, both databases

One transcript per database, each showing the HQL, the SQL Hibernate generated, and the result: output/04-hql-json-functions-postgresql.txt and output/08-mysql.txt. The shared statement list is A04_HqlJsonFunctionsTest.java (cases()) and the runner HqlRunner.java.

Also in the PostgreSQL transcript: json_mergepatch generates a recursive CTE of several hundred characters, so use it knowing what it costs; the plain jsonb_set-based modifiers are one function call.

The best query looks like Java: an @Embeddable stored as JSON

If your document has a known shape, map it as an @Embeddable and annotate the attribute with @JdbcTypeCode(SqlTypes.JSON). The column is still one jsonb, but Hibernate now knows the attribute names and types, so HQL can navigate into it with a plain dot — no json_value, no switch to flip:

From src/test/java/com/ankurm/jsoncolumns/A05_EmbeddableJsonTest.java:

    @Embeddable
    public record Address(String city, String zip, int floor) { }

    @Entity(name = "Customer") @Table(name = "customer")
    static class Customer {
        @Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
        String name;
        @JdbcTypeCode(SqlTypes.JSON) Address address;
        Customer() { }

A where on c.address.city becomes a ->> extraction with a cast to the declared Java type: Captured in output/05-embeddable-json-postgresql.txt:

sql> select c1_0.name from customer c1_0 where cast(c1_0.address->>'city' as varchar)='Pune' order by c1_0.id
result: [Asha, Meera]

And an HQL update of one field patches the document server-side instead of rewriting it from Java: Captured in output/05-embeddable-json-postgresql.txt:

sql> update customer c1_0 set address=coalesce(c1_0.address,'{}')||jsonb_build_object('floor',to_jsonb(99)) where c1_0.name='Ravi'
rows updated: 1; stored: {"zip": "400001", "city": "Mumbai", "floor": 99}

That cast matters for the next section: cast(... as integer) on an int attribute is what lets an expression index on (payload->>'userId')::int serve the query. On MySQL the same c.address.city HQL worked too (it becomes json_unquote(json_extract(..)); the line is in the MySQL transcript).

Going deeper: limits I did not explore

I ran selects, an order by, a group by and one update on a record-typed embeddable with three scalar fields. I did not test embeddables nested in embeddables, collections inside the JSON, or inheritance in the document. The full transcript is output/05-embeddable-json-postgresql.txt.

<

Make it fast: GIN, jsonb_path_ops and expression indexes, with plans

A JSON column without an index is a table scan with extra steps. I created the event table from a Hibernate entity, filled it with 50,000 documents using generate_series, and ran four queries under EXPLAIN (ANALYZE) with no index, then each index type in turn. The documents look like this:

Captured in output/07-indexing-explain.txt:

rows: 50000
sample: {"tags": ["standard"], "type": "purchase", "amount": 4.2000000000000000, "device": {"os": "linux", "version": 8}, "userId": 42}
table size (heap): 10000 kB
Pick the index from the shape of the query, not from the columnpayload @> ‘{“userId”:42}’GIN (jsonb_ops or jsonb_path_ops)plan: Bitmap Index Scanpayload ? ‘coupon’GIN jsonb_ops onlyjsonb_path_ops: Seq Scanpayload->>’userId’ = ’42’b-tree on that exact expressionGIN does not help: Seq Scanjson_value(payload, ‘$.userId’) = ’42’no index I tried was usedplan: Seq ScanEach row was run with EXPLAIN (ANALYZE) on 50,000 rows; the plans are in output/07-indexing-explain.txt.

The diagram is the whole decision: containment (@>) and key exists (?) are what a GIN index answers; equality on one extracted field is what a plain b-tree on that exact expression answers; and a query written in a way the index expression does not match gets neither.

The four indexes, as the test creates them: From src/test/java/com/ankurm/jsoncolumns/A07_IndexingTest.java:

Db.exec(url, Db.PG_USER, "create index idx_event_gin on event using gin (payload)");
Db.exec(url, Db.PG_USER, "create index idx_event_gin_path on event using gin (payload jsonb_path_ops)");
Db.exec(url, Db.PG_USER, "create index idx_event_user_text on event ((payload->>'userId'))");
Db.exec(url, Db.PG_USER, "create index idx_event_user_int on event (((payload->>'userId')::int))");

Q1, containment of {"userId":42,"type":"purchase"}, before: a sequential scan reads all 50,000 rows and throws away 49,990. Captured in output/07-indexing-explain.txt:

Aggregate (actual time=10.129..10.130 rows=1.00 loops=1)
  ->  Seq Scan on event (actual time=0.027..10.113 rows=10.00 loops=1)
        Filter: (payload @> '{"type": "purchase", "userId": 42}'::jsonb)
        Rows Removed by Filter: 49990
Planning Time: 0.362 ms
Execution Time: 10.185 ms

Q1 after create index ... using gin (payload): a bitmap index scan finds the 10 rows. Captured in output/07-indexing-explain.txt:

Aggregate (actual time=0.626..0.627 rows=1.00 loops=1)
  ->  Bitmap Heap Scan on event (actual time=0.572..0.622 rows=10.00 loops=1)
        Recheck Cond: (payload @> '{"type": "purchase", "userId": 42}'::jsonb)
        Heap Blocks: exact=10
        ->  Bitmap Index Scan on idx_event_gin (actual time=0.561..0.561 rows=10.00 loops=1)
              Index Cond: (payload @> '{"type": "purchase", "userId": 42}'::jsonb)
              Index Searches: 1
Planning Time: 0.413 ms
Execution Time: 0.676 ms
Execution time per query and index, ms (log scale, this machine, indicative)Q1 @> containmentno index10.2 msGIN jsonb_ops0.676 msGIN jsonb_path_ops0.177 msQ2 ->> equalityno index24.4 msGIN jsonb_ops27.1 msb-tree expression0.134 msQ3 ? key existsno index15.3 msGIN jsonb_ops0.471 msGIN jsonb_path_ops7.89 msGreen = index used and effective; amber = index present but the planner ignored it for this query; blue = the matching b-tree.Same 50,000 rows throughout; absolute numbers move between runs, the ratios and plan shapes do not.

The chart is the same story in time: Q1 containment dropped from 10.2 ms to 0.68 ms with the default GIN and 0.18 ms with jsonb_path_ops, and the tag containment Q4 from 20.1 ms to about 2.9 ms on both; jsonb_path_ops is the faster and (see the sizes below) smaller of the two — but it cannot answer ?, which falls back to a sequential scan. Equality on ->>'userId' is not helped by a GIN index at all; only the b-tree on that expression turns it into an index scan. The timings are from this sandbox and move between runs; the plan shapes are what the test asserts.

Index sizes against a 10 MB heap: Captured in output/07-indexing-explain.txt:

idx_event_gin: 1840 kB
idx_event_gin_path: 1400 kB
idx_event_user_text: 480 kB
idx_event_user_int:  480 kB

The jsonb_path_ops limit, Q3 (payload ? 'coupon'), in the plan: Captured in output/07-indexing-explain.txt:

Aggregate (actual time=7.851..7.853 rows=1.00 loops=1)
  ->  Seq Scan on event (actual time=0.076..7.829 rows=100.00 loops=1)
        Filter: (payload ? 'coupon'::text)
        Rows Removed by Filter: 49900

Indexes are not declared on the entity. JPA’s @Index takes a list of columns, which cannot say using gin or jsonb_path_ops or an expression. Put these in your migration tool (see Flyway vs Liquibase on Spring Boot 4), and let hbm2ddl create only the tables, as the test does.

The index only helps if Hibernate writes the SQL your index expects

With the b-tree expression indexes in place I took three HQL spellings of “userId is 42”, captured the SQL Hibernate generated, and explained exactly that SQL:

HQL json_value(e.payload, '$.userId') = '42': Captured in output/07-indexing-explain.txt:

sql> select count(*) from event e1_0 where json_value(e1_0.payload,'$.userId' returning jsonb)#>>'{}'='42'
Aggregate (actual time=43.806..43.809 rows=1.00 loops=1)
  ->  Seq Scan on event e1_0 (actual time=0.060..43.792 rows=10.00 loops=1)
        Filter: ((JSON_VALUE(payload, '$."userId"' RETURNING jsonb) #>> '{}'::text[]) = '42'::text)

HQL e.payload.userId = 42 on the typed @Embeddable view of the same table: Captured in output/07-indexing-explain.txt:

sql> select count(*) from event te1_0 where cast(te1_0.payload->>'userId' as integer)=42
Aggregate (actual time=0.078..0.078 rows=1.00 loops=1)
  ->  Bitmap Heap Scan on event te1_0 (actual time=0.027..0.071 rows=10.00 loops=1)
        Recheck Cond: (((payload ->> 'userId'::text))::integer = 42)
        Heap Blocks: exact=10
        ->  Bitmap Index Scan on idx_event_user_int (actual time=0.015..0.015 rows=10.00 loops=1)
              Index Cond: (((payload ->> 'userId'::text))::integer = 42)

HQL function('jsonb_extract_path_text', ...): Captured in output/07-indexing-explain.txt:

sql> select count(*) from event e1_0 where jsonb_extract_path_text(e1_0.payload,'userId')='42'
Aggregate (actual time=12.766..12.767 rows=1.00 loops=1)
  ->  Seq Scan on event e1_0 (actual time=0.028..12.753 rows=10.00 loops=1)
        Filter: (jsonb_extract_path_text(payload, VARIADIC '{userId}'::text[]) = '42'::text)

Only the typed embeddable form generated cast(te1_0.payload->>'userId' as integer)=42, which matches the (payload->>'userId')::int index. json_value generated JSON_VALUE(... RETURNING jsonb) #>> '{}', which is a different expression, so the planner scanned the table; the jsonb_extract_path_text function form did too. I did not try to build an index on those expressions. The practical rule: for a field you filter on constantly, either use the typed embeddable or write the native query to match the index you created, and then look at EXPLAIN once.

Going deeper: what this does not measure

One table, one machine, 50,000 rows, warm cache, one query at a time. I did not measure insert or update cost with the indexes in place (GIN indexes cost more to maintain than b-trees), index bloat, or behaviour with documents large enough to be TOASTed. The test, with every query and every plan, is A07_IndexingTest.java and output/07-indexing-explain.txt.

<

Does Hibernate notice when you change the JSON in place?

Short answer: yes, for Map, List, records and POJOs — with two traps. Hibernate decides what to UPDATE at flush time by comparing each attribute with a snapshot taken when the entity was loaded. For a JSON attribute that snapshot is a deep copy, and the copy is made the only way Hibernate can copy an arbitrary object graph: serialise it to JSON text and parse it back. Then it asks Objects.equals(snapshot, current). I did not want to infer that from behaviour alone, so I dumped the bytecode:

The snapshot, in hibernate-core 7.4.14 (FormatMapperBasedJavaType.deepCopy calls toString then fromString): Captured in output/12-javap-deepcopy.txt:

  public T deepCopy(T);
    Code:
         0: aload_1
         1: ifnonnull     8
         4: aconst_null
         5: goto          17
         8: aload_0
         9: aload_0
        10: aload_1
        11: invokevirtual #82                 // Method toString:(Ljava/lang/Object;)Ljava/lang/String;
        14: invokevirtual #85                 // Method fromString:(Ljava/lang/CharSequence;)Ljava/lang/Object;
        17: areturn

and the comparison (AbstractJavaType.areEqual): Captured in output/12-javap-deepcopy.txt:

  public boolean areEqual(T, T);
    Code:
         0: aload_1
         1: aload_2
         2: invokestatic  #51                 // Method java/util/Objects.equals:(Ljava/lang/Object;Ljava/lang/Object;)Z
         5: ireturn
How Hibernate decides a JSON attribute is dirtyon load: snapshotparse(toString(value))a deep copy made by JSONmap.put(“color”, “blue”)list.add(..), setter on a POJOequals() says different1 UPDATE (correct)unchanged: Map, List, record,POJO that has equals()equals() says sameno SQLunchanged POJOWITHOUT equals()Object identity: never equalUPDATE on every flushMap holding BigDecimal 19.90(snapshot holds Double 19.9)not equal on first flushUPDATE right after INSERTAt flush Hibernate compares the snapshot with the live value using Objects.equals.So the question is never “did you touch it” but “is it equal to its own copy”.

The diagram is the mental model: “dirty” means “not equal to a copy of itself that went through JSON”. A real change is not equal, so you get one UPDATE; an untouched value that equals its copy gives no SQL. The two bottom rows are where that definition bites. Here are the nine mutations I tried, each on a freshly loaded Product:

Every in-place change was detected; changing nothing, or setting an equal record, issued no UPDATE: Captured in output/03-dirty-checking.txt:

attributes.put("color", "blue")                                      -> 1 UPDATE
attributes.remove("stock")                                           -> 1 UPDATE
((Map) attributes.get("nested")).put("k2", 1)  [nested map]          -> 1 UPDATE
((List) nested.get("k")).add(4)  [list inside map]                   -> 1 UPDATE
tags.add("c")                                                        -> 1 UPDATE
specs.setMaterial("brass")  [POJO setter]                            -> 1 UPDATE
specs.getCertifications().add("UL")  [list inside POJO]              -> 1 UPDATE
setDimensions(new Dimensions(9,9,9))  [record replaced]              -> 1 UPDATE
setDimensions(equal record)  [no real change]                        -> 0 UPDATE

The statement for each of them is the same, and it rewrites every column: Captured in output/03-dirty-checking.txt:

      update product set attributes=?,dimensions=?,name=?,specs=?,tags=? where id=?

That is plain Hibernate behaviour (all columns by default), but with a multi-kilobyte JSON column it is worth knowing: touching a tag rewrites the attributes, specs and dimensions documents too. @DynamicUpdate restricts the statement to the changed columns:

With @DynamicUpdate on an entity with two JSON columns a and b, changing only a: Captured in output/03-dirty-checking.txt:

sql> update dyn set a=? where id=?
A POJO without equals() is dirty on every flush. Load a row whose JSON attribute is a plain class that does not override equals(), change nothing, commit: Hibernate sends an UPDATE anyway, because the snapshot (a new object) is never equal to the live one by identity. The module’s Specs POJO has equals()/hashCode() and produced zero UPDATEs; LooseSpecs, the same class without them, produced one. Records get equals() for free, which is a good reason to prefer them.

The two spurious-UPDATE experiments, in order (a BigDecimal inside a map, then the POJO without equals(), then the one with): Captured in output/03-dirty-checking.txt:

persist() with attributes.price = new BigDecimal("19.90") -> 1 UPDATE right after the INSERT
   sql> insert into product (attributes,dimensions,name,specs,tags) values (?,?,?,?,?)
   sql> update product set attributes=?,dimensions=?,name=?,specs=?,tags=? where id=?
stored price after that persist: 19.90
then a plain load + commit of that row -> 0 UPDATE
load + commit of a row whose POJO has NO equals(), nothing touched -> 1 UPDATE
   sql> select l1_0.id,l1_0.specs from loose l1_0 where l1_0.id=?
   sql> update loose set specs=? where id=?
same for Specs, which HAS equals() (Product row, nothing touched) -> 0 UPDATE
Going deeper: the same two causes, one fix each

The BigDecimal case is the same mechanism: the live map holds BigDecimal("19.90"), the snapshot (parsed back from JSON) holds Double 19.9, they are not equal, so the very first flush after persist issues an UPDATE. After it, the stored value stayed 19.90 and a later plain load produced no UPDATE. Fix: type the field (a record or POJO with a BigDecimal component), or keep only JSON-native types in the map.

Everything above is in A03_DirtyCheckingTest.java and output/03-dirty-checking.txt; the bytecode is output/12-javap-deepcopy.txt from scripts/javap.sh.

If you would rather patch a document in the database without loading it, the HQL modifiers from the previous sections (json_set and friends) do that, at the price of leaving any already-loaded entity stale.

Which JSON library does Hibernate actually use? It depends on what else is on the classpath

Spring Boot 4 moved to Jackson 3 (tools.jackson), and Hibernate 7.4 ships a mapper for both generations. Which one it chooses is decided at startup from the classpath, and the choice changes how your documents are written (dates, unknown fields, naming). I wrote a small main() that boots Hibernate, prints the chosen mapper, then round-trips a map and a record holding an Instant, and ran it under different classpaths built with Maven profiles:

Which FormatMapper Hibernate 7.4.14 picks, by what is on the classpathJackson 3 only (Spring Boot 4 default)Jackson3JsonFormatMapperJackson 2 onlyJacksonJsonFormatMapperjava.time needs jackson-datatype-jsr310Jackson 2 and Jackson 3 togetherJacksonJsonFormatMapperJackson 2 wins, silentlyneither Jackson nor JSON-BHibernateException at startup:no FormatMapper for JSONhibernate.type.json_format_mapper = jackson | jackson3 | jsonb | a FormatMapper instance overrides the automatic choice.Measured with a probe run under nine classpath and setting combinations: output/09-format-mapper-matrix.txt.

Read the diagram top to bottom: the automatic choice depends only on which Jackson generations are present, and the runs below are the evidence for each row, copied from the probe’s output.

Jackson 3 only — what a Spring Boot 4 application gets: Captured in output/09-format-mapper-matrix.txt:

== A. Jackson 3 only, setting not set
setting     : hibernate.type.json_format_mapper = (not set)
on classpath: jackson2=false jackson3=true jakarta.json=false
mapper      : org.hibernate.type.format.jackson.Jackson3JsonFormatMapper
stored      : {"n": 1, "list": ["a", "b"]} ; {"at": 1793355330.000000000, "label": "boot"}
read back   : {n=1, list=[a, b]} ; Stamp[label=boot, at=2026-10-30T10:15:30Z]

Add Jackson 2 next to it (any transitive dependency can do this) and Hibernate switches: Captured in output/09-format-mapper-matrix.txt:

== B. Jackson 2 + Jackson 3, setting not set
setting     : hibernate.type.json_format_mapper = (not set)
on classpath: jackson2=true jackson3=true jakarta.json=false
mapper      : org.hibernate.type.format.jackson.JacksonJsonFormatMapper
FAILED      : com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8 date/time type `java.time.Instant` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (or disable `MapperFeature.REQUIRE_HANDLERS_FOR_JAVA8_TIMES`) (through reference chain: com.ankurm.jsoncolumns.FormatMapperProbe$Stamp["at"])
Jackson 2 wins when both are present, and nothing says so. With both on the classpath and no setting, Hibernate 7.4.14 used JacksonJsonFormatMapper (Jackson 2). Your Boot 4 JsonMapper configuration is irrelevant to it, and a Jackson 2 mapper without jackson-datatype-jsr310 cannot write an Instant inside a JSON attribute (the FAILED line above). With jackson-datatype-jsr310 added the same round trip passed (cases H and I in the full matrix). To pin the choice, set hibernate.type.json_format_mapper=jackson3 (case G worked with both present).

And with no Jackson at all the application does not start: Captured in output/09-format-mapper-matrix.txt:

== D. no Jackson at all, setting not set
setting     : hibernate.type.json_format_mapper = (not set)
on classpath: jackson2=false jackson3=false jakarta.json=false
FAILED      : org.hibernate.HibernateException: Could not find a FormatMapper for the JSON format, which is required for mapping JSON types. JSON FormatMapper configuration is automatic, but requires that you have either Jackson or a JSONB implementation like Yasson on the class path.

Setting the property to jackson on a classpath without Jackson 2 failed too (case F: ClassNotFoundException: com.fasterxml.jackson.core.JsonProcessingException), so it is a switch between installed libraries, not a way to conjure one.

Make Hibernate use the same JsonMapper as the rest of the application

Boot’s JsonMapper bean and Hibernate’s internal mapper are two different objects. I compared them by reflection in a running Boot context:

Default Boot wiring: Captured in output/11-spring-boot.txt:

JsonMapper bean from Boot : tools.jackson.databind.json.JsonMapper
Hibernate FormatMapper    : org.hibernate.type.format.jackson.Jackson3JsonFormatMapper
same JsonMapper instance? : false

A HibernatePropertiesCustomizer hands Boot’s mapper to Hibernate (the module activates it with the profile shared-mapper): From src/main/java/com/ankurm/jsoncolumns/model/SharedMapperConfig.java:

@Configuration
@Profile("shared-mapper")
public class SharedMapperConfig {

    @Bean
    HibernatePropertiesCustomizer hibernateUsesBootsJsonMapper(JsonMapper bootMapper) {
        return props -> props.put("hibernate.type.json_format_mapper", new Jackson3JsonFormatMapper(bootMapper));
    }

With that customizer active: Captured in output/11-spring-boot.txt:

same JsonMapper instance? : true

The customizer interface is org.springframework.boot.hibernate.autoconfigure.HibernatePropertiesCustomizer in Boot 4 (the package moved in 4.0). The same property accepts any FormatMapper instance, which is how I tested a snake_case JsonMapper too: records were written as {"depth_mm": 3, "width_mm": 1, "height_mm": 2} and read back equal.

Going deeper: all nine classpaths, and the probe

The full matrix (cases A to I) is output/09-format-mapper-matrix.txt, produced by scripts/format-mapper-matrix.sh running FormatMapperProbe.java with a classpath per case. One detail I had to work around: Maven’s mdep.excludeGroupIds did not remove tools.jackson from the resolved classpath, so the script filters the classpath file instead. I did not test hibernate.type.json_format_mapper=jsonb (Yasson), nor Oracle’s OSON mapper.

Rows outlive your code: what happens when the stored JSON no longer matches the class

A JSON column has no schema, so a deploy that adds, removes or renames a field in your record meets old rows — and a rollback meets new ones. I inserted four deliberately mismatched documents by hand and loaded each into the Dimensions record:

With Jackson 3’s mapper as Hibernate configures it: Captured in output/09-format-mapper-inprocess.txt:

extra-key     -> FAILED tools.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized property "colour" (class com.ankurm.jsoncolumns.model.Dimensions), not marked as ignorable (3 known properties: "widthMm", "heightMm", "depthMm")
missing-key   -> loaded Dimensions[widthMm=1, heightMm=0, depthMm=0]
wrong-type    -> FAILED tools.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `int` from String "wide": not a valid `int` value
not-an-object -> FAILED tools.jackson.databind.exc.MismatchedInputException: Cannot deserialize value of type `com.ankurm.jsoncolumns.model.Dimensions` from Array value (token `JsonToken.START_ARRAY`)

A missing key is forgiven: the record’s int component became 0 (silently — a stored {"widthMm":1} is now a 1×0×0 box). An unknown key is fatal, which is the one that hurts: if version 2 of your application writes a new field and you roll back to version 1, every read of those rows fails. The fix is one annotation from jackson-annotations:

From src/test/java/com/ankurm/jsoncolumns/A09_FormatMapperTest.java:

    @com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)
    public record Tolerant(int widthMm) { }

With it, the extra key is ignored on read: Captured in output/09-format-mapper-inprocess.txt:

stored {"widthMm":7,"colour":"red"} -> loaded Tolerant[widthMm=7]

A migration tip that falls out of this. Treat JSON documents like a public API: add fields as optional, give new primitives a deliberate default (use boxed types such as Integer if “missing” must be distinguishable from 0), and put @JsonIgnoreProperties(ignoreUnknown = true) on records you might have to roll back. I only tested the forward case in this module; I did not test renamed fields or versioned documents.

MySQL: the same annotation, different SQL

The annotation does not change on MySQL. The column is MySQL’s native json type, the SQL differs, and indexing works differently. Everything below ran on a real MySQL 8.0.46 server:

DDL, column types, how MySQL stored a record, and dirty checking (same result as PostgreSQL): Captured in output/08-mysql.txt:

create table product (id bigint not null auto_increment, name varchar(255), attributes json, dimensions json, specs json, tags json, primary key (id)) engine=InnoDB;
attributes json
dimensions = {"depthMm": 180, "widthMm": 180, "heightMm": 360}
load, change nothing: 0 UPDATE; attributes.put("color","teal"): 1 UPDATE

MySQL’s own functions in native queries (? is not an operator here, so there is no escaping trap): Captured in output/08-mysql.txt:

->> text extraction -> Floor lamp; Office chair
JSON_CONTAINS(tags, '"office"') -> Desk lamp; Office chair; Monitor arm
'office' MEMBER OF (tags) -> Desk lamp; Office chair; Monitor arm
JSON_OVERLAPS(tags, '["home","accessories"]') -> Floor lamp; Bookshelf; Monitor arm

For indexing, MySQL cannot index a JSON column directly. It indexes an expression over it (a functional index), and for JSON arrays it has a multi-valued index that serves MEMBER OF. On 50,000 rows:

From src/test/java/com/ankurm/jsoncolumns/A08_MySqlTest.java:

Db.exec(url, Db.MYSQL_USER, "create index idx_user on event ((cast(payload->>'$.userId' as unsigned)))");
Db.exec(url, Db.MYSQL_USER, "create index idx_tags on event ((cast(payload->'$.tags' as char(10) array)))");

Before (type=ALL is a full table scan): Captured in output/08-mysql.txt:

sql> select count(*) from event where payload->>'$.userId' = '42'
type=ALL key=null rows=49800 extra=Using where
sql> select count(*) from event where cast(payload->>'$.userId' as unsigned) = 42
type=ALL key=null rows=49800 extra=Using where
sql> select count(*) from event where 'vip' member of (payload->'$.tags')
type=ALL key=null rows=49800 extra=Using where

After: Captured in output/08-mysql.txt:

sql> select count(*) from event where payload->>'$.userId' = '42'
type=ALL key=null rows=49800 extra=Using where
sql> select count(*) from event where cast(payload->>'$.userId' as unsigned) = 42
type=ref key=idx_user rows=10 extra=null
sql> select count(*) from event where 'vip' member of (payload->'$.tags')
type=ref key=idx_tags rows=1000 extra=Using where
count via index: 10 / vip rows: 1000

The same lesson as on PostgreSQL: the index serves the expression it was built on (cast(payload->>'$.userId' as unsigned) = 42) and not the neighbouring spelling without the cast (type=ALL again for payload->>'$.userId' = '42'). The multi-valued index on tags was picked for 'vip' member of (payload->'$.tags').

Going deeper: MariaDB and everyone else

Hibernate 7.4’s jar has dedicated JSON function classes for MariaDB, Oracle, SQL Server, DB2, HANA, H2, HSQLDB and CockroachDB as well. I tested only PostgreSQL and MySQL; those others are NOT TESTED here. Whatever you read about them should be labelled as documentation, not as something this module verified. The MySQL transcript, including all HQL results, is output/08-mysql.txt from A08_MySqlTest.java.

In Spring Boot and Spring Data: the same mappings, three repository styles

Inside a Boot 4.1.2 application nothing about the entity changes. The repository can use a native query, an HQL query with a JSON function, or a derived query on a whole record value; all three ran in a real Spring context against PostgreSQL:

From src/main/java/com/ankurm/jsoncolumns/model/ProductRepository.java:

    @Query(value = "select * from product where attributes @> cast(:json as jsonb) order by id", nativeQuery = true)
    List<Product> findByAttributesContaining(@Param("json") String json);
    @Query("select p from Product p where json_value(p.attributes, '$.color') = :color order by p.id")
    List<Product> findByColor(@Param("color") String color);
    @Modifying
    @Query("update Product p set p.attributes = json_set(p.attributes, '$.color', :color) where p.id = :id")
    int repaint(@Param("id") Long id, @Param("color") String color);
    List<Product> findByDimensions(Dimensions dimensions);

Results from the Spring context test: Captured in output/11-spring-boot.txt:

native @> (findByAttributesContaining {"color":"black"}): [Floor lamp, Office chair]
HQL json_value (findByColor black)                       : [Floor lamp, Office chair]
derived query findByDimensions(new Dimensions(300,600,300)): [Floor lamp]
@Modifying json_set repaint(1, "green") -> 1 row; color now green

The derived query findByDimensions(Dimensions) compares the whole jsonb value for equality, which works on jsonb (and would not on json, per the earlier section). The HQL functions needed the Boot-side property the earlier callout names; the test passes it as --spring.jpa.properties.hibernate.query.hql.json_functions_enabled=true.

Going deeper: why plain Hibernate in the other tests

Ten of the eleven test classes bootstrap Hibernate directly (Db.java) so that every setting is visible and the SQL can be captured with a StatementInspector. Only A11_SpringBootTest.java starts Spring. The Boot-specific facts are in output/11-spring-boot.txt. Related reading on this blog: Hibernate 7 with Spring Boot 4 and Spring Data JDBC vs JPA.

Why so many answers mention hibernate-types and @TypeDef — and what is actually outdated

Search for this topic and you find years of answers that add a library, declare a @TypeDef(name = "jsonb", typeClass = JsonBinaryType.class), and annotate the field with @Type(type = "jsonb"). I checked what is still true, using only things I could run or read from Maven Central:

Against Hibernate 7.4.14.Final, the annotations that recipe needs: Captured in output/10-hypersistence-jackson3-only.txt:

== what the 2018-2022 recipes rely on, checked against Hibernate 7.4.14.Final
org.hibernate.annotations.TypeDef present? false
org.hibernate.annotations.TypeDefs present? false
org.hibernate.annotations.Type attributes: [parameters, value]

The two library lines, from Maven Central: Captured in output/13-maven-facts.txt:

com.vladmihalcea:hibernate-types-60 : release=2.21.1 lastUpdated=20221224094656
io.hypersistence:hypersistence-utils-hibernate-73: release=3.16.0 lastUpdated=20260920123251
hypersistence-utils-hibernate-73-3.16.0.jar: Sun, 20 Sep 2026 12:31:01 GMT
<hibernate.version>7.4.8.Final</hibernate.version>

So two things are outdated: the API (@TypeDef and @Type(type = ...) do not exist in 7.4.14; @Type now has only value and parameters), and the coordinates (com.vladmihalcea:hibernate-types-* stopped at 2.21.1 in December 2022; the successor is io.hypersistence:hypersistence-utils-hibernate-*). What is not outdated is the library itself: 3.16.0 was published on 2026-09-20 and its -73 build targets Hibernate 7.4.8. I ran its JsonType on 7.4.14:

On a Jackson 3 only classpath (plus hypersistence-utils-hibernate-73): Captured in output/10-hypersistence-jackson3-only.txt:

getJsonFormatMapper() -> org.hibernate.type.format.jackson.Jackson3JsonFormatMapper

== @Type(JsonType.class) @Column(columnDefinition = "jsonb") on Hibernate 7.4.14
stored: jsonb {"k": "v", "n": 1}
in-place Map.put -> 1 UPDATE; stored: {"k": "changed", "n": 1}

With Jackson 2 added to the same classpath, Hibernate’s own choice flips again: Captured in output/10-hypersistence-with-jackson2.txt:

getJsonFormatMapper() -> org.hibernate.type.format.jackson.JacksonJsonFormatMapper

It works, including change detection on an in-place Map.put. But @Type(JsonType.class) plus a columnDefinition buys nothing over @JdbcTypeCode(SqlTypes.JSON) for plain JSON mapping on Hibernate 7. I did not test any of the library’s other types, its JSON query helpers, or the claim in its own documentation about when to prefer one approach over the other, so I make no statement about them.

Going deeper: how this was checked

The hypersistence Maven profile adds the jar and a second test source folder, so the default build needs neither. Run with -Phypersistence (Jackson 3 only) and -Phypersistence,jackson2; the tests are A10_HypersistenceTest.java. Maven facts come from scripts/maven-facts.sh. I did not read the individual old blog posts line by line; what I can show is the API facts above.

What I did not test

Not tested, so not claimed. Oracle, SQL Server, DB2, HANA, H2 and MariaDB JSON dialects (NOT TESTED; Hibernate’s jar contains code for them, I ran none of it); PostgreSQL older than 18.6; Hibernate 8; SqlTypes.JSON_ARRAY; JSON-B (Yasson) as the format mapper; documents large enough to be TOASTed; write cost of GIN indexes; concurrent updates; the behaviour of the query features on a json (not jsonb) column; collections or nested embeddables inside a JSON document; renamed-field migrations. The PostgreSQL server was a zonky embedded-binaries build (18.6, compiled with gcc 7.5 per select version()) and MySQL was Ubuntu’s 8.0.46 package, both started without Docker; a production-grade server may plan differently. Timings are from a 2-core sandbox and are indicative only.

Should you even store this as JSON?

Honest answer: often not. Use a JSON column for data that is genuinely variable or opaque — vendor payloads, per-tenant settings, audit snapshots. If you filter, sort, join or aggregate on a field in most requests, make it a column; this module needed a different index for every query shape (GIN for containment, a second GIN flavour for key-exists, a b-tree per extracted field), the HQL function spelling did not use the index at all, and JSON has no foreign keys or column-level constraints. If the shape is known, prefer a typed record or @Embeddable over a free-form map: it avoided the BigDecimal and date surprises, it gave HQL a dot-navigation that produced an index-friendly cast, and a record never triggers the spurious UPDATE. And decide the compatibility story up front (extra keys are fatal on read, missing keys silently default), because the stored rows will outlive any one version of your class.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.