Problem with storing LinkedHashMaps as json column

Hi,

we are building a kotlin application which is based on springboot3.5.6 + hibernate6

and we are facing an LinkedHashMap ordering issue

we have an objects which is stored in postgresql14

@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "compound_rules", columnDefinition = "json")
var compoundRules: LinkedHashMap<String, CompoundRuleData>,

in the database side columsn are properly defined as “json” type and not “jsonb”

and it seems that the order of the compoundRules elements , which should be element insertion order is not kept.
tracing log information shows that the PGStatement is properly created and the following Release message is printed (note the ids order)

2025-11-09 12:21:47,140 [TRACE] [http-nio-8080-exec-4] [opId=ae543957aff2] [userEmail=admin@fmanager.com] [ResourceRegistryStandardImpl.java:96] Releasing statement [/* update for com.foretellix.manager.services.workspace.WorkspaceCompoundRulesData */update workspace_compound_rules set compound_rules=(‘{“4d3f85b4962c50fd7f38”:{“id”:“4d3f85b4962c50fd7f38”,“name”:“test_rule_0”,“modifiedAt”:1762683706,“lastModifiedById”:“42e6b2f773365a5fae82”,“createdById”:“42e6b2f773365a5fae82”,“rulesParams”:{“temporalRelationParams”:{“intervalFilters”:[{“elements”:[{“_type”:“intervals”,“include”:true,“scenarioName”:null,“protected”:false,“logicalOperator”:“AND”,“intervalName”:“sut.lead_vehicle”,“intervalType”:“MatchIntervalData”,“childrenFilter”:null,“intervalCriteria”:}],“logicalOperator”:“AND”,“isEmpty”:false},{“elements”:[{“_type”:“intervals”,“include”:true,“scenarioName”:null,“protected”:false,“logicalOperator”:“AND”,“intervalName”:“sut.follower_vehicle”,“intervalType”:“MatchIntervalData”,“childrenFilter”:null,“intervalCriteria”:}],“logicalOperator”:“AND”,“isEmpty”:false}],“timeRelation”:“ANY_INTERSECTION”,“customTimeRelation”:null,“workspaceId”:“4a4cafc9c441ec77fc08”},“temporalAction”:“UNION”,“nameParams”:{“name”:“test_interval”,“concatNames”:false,“delimiter”:“_”},“metricGroupPrefix”:null,“corrMetricGroupPrefix”:null},“creationContext”:“WORKSPACE”,“createdAt”:1762683706},“414981a5b9d77c76829a”:{“id”:“414981a5b9d77c76829a”,“name”:“test_rule_1”,“modifiedAt”:1762683707,“lastModifiedById”:“42e6b2f773365a5fae82”,“createdById”:“42e6b2f773365a5fae82”,“rulesParams”:{“temporalRelationParams”:{“intervalFilters”:[{“elements”:[{“_type”:“intervals”,“include”:true,“scenarioName”:null,“protected”:false,“logicalOperator”:“AND”,“intervalName”:“sut.lead_vehicle”,“intervalType”:“MatchIntervalData”,“childrenFilter”:null,“intervalCriteria”:}],“logicalOperator”:“AND”,“isEmpty”:false},{“elements”:[{“_type”:“intervals”,“include”:true,“scenarioName”:null,“protected”:false,“logicalOperator”:“AND”,“intervalName”:“sut.follower_vehicle”,“intervalType”:“MatchIntervalData”,“childrenFilter”:null,“intervalCriteria”:}],“logicalOperator”:“AND”,“isEmpty”:false}],“timeRelation”:“ANY_INTERSECTION”,“customTimeRelation”:null,“workspaceId”:“4a4cafc9c441ec77fc08”},“temporalAction”:“UNION”,“nameParams”:{“name”:“test_interval”,“concatNames”:false,“delimiter”:“_”},“metricGroupPrefix”:null,“corrMetricGroupPrefix”:null},“creationContext”:“WORKSPACE”,“createdAt”:1762683707}}’),version=(‘2’::int4),workspace_id=(‘4a4cafc9c441ec77fc08’) where id=(‘4643b0cbd1fce539cc70’)]

however, if looking at the postgresql log message exactly at that timestamp we will see that the order of elements is reversed (note again the ids)

2025-11-09 12:21:47.140 IST [3182906] fmanager@fmanager LOG: execute : /* update for com.foretellix.manager.services.workspace.WorkspaceCompoundRulesData */update workspace_compound_rules set compound_rules=$1,version=$2,workspace_id=$3 where id=$4

2025-11-09 12:21:47.140 IST [3182906] fmanager@fmanager DETAIL: parameters: $1 = ‘{“414981a5b9d77c76829a”: {“id”: “414981a5b9d77c76829a”, “name”: “test_rule_1”, “createdAt”: 1762683707, “modifiedAt”: 1762683707, “createdById”: “42e6b2f773365a5fae82”, “rulesParams”: {“nameParams”: {“name”: “test_interval”, “delimiter”: “_”, “concatNames”: false}, “temporalAction”: “UNION”, “metricGroupPrefix”: null, “corrMetricGroupPrefix”: null, “temporalRelationParams”: {“workspaceId”: “4a4cafc9c441ec77fc08”, “timeRelation”: “ANY_INTERSECTION”, “intervalFilters”: [{“isEmpty”: false, “elements”: [{“_type”: “intervals”, “include”: true, “protected”: false, “intervalName”: “sut.lead_vehicle”, “intervalType”: “MatchIntervalData”, “scenarioName”: null, “childrenFilter”: null, “logicalOperator”: “AND”, “intervalCriteria”: }], “logicalOperator”: “AND”}, {“isEmpty”: false, “elements”: [{“_type”: “intervals”, “include”: true, “protected”: false, “intervalName”: “sut.follower_vehicle”, “intervalType”: “MatchIntervalData”, “scenarioName”: null, “childrenFilter”: null, “logicalOperator”: “AND”, “intervalCriteria”: }], “logicalOperator”: “AND”}], “customTimeRelation”: null}}, “creationContext”: “WORKSPACE”, “lastModifiedById”: “42e6b2f773365a5fae82”}, “4d3f85b4962c50fd7f38”: {“id”: “4d3f85b4962c50fd7f38”, “name”: “test_rule_0”, “createdAt”: 1762683706, “modifiedAt”: 1762683706, “createdById”: “42e6b2f773365a5fae82”, “rulesParams”: {“nameParams”: {“name”: “test_interval”, “delimiter”: “_”, “concatNames”: false}, “temporalAction”: “UNION”, “metricGroupPrefix”: null, “corrMetricGroupPrefix”: null, “temporalRelationParams”: {“workspaceId”: “4a4cafc9c441ec77fc08”, “timeRelation”: “ANY_INTERSECTION”, “intervalFilters”: [{“isEmpty”: false, “elements”: [{“_type”: “intervals”, “include”: true, “protected”: false, “intervalName”: “sut.lead_vehicle”, “intervalType”: “MatchIntervalData”, “scenarioName”: null, “childrenFilter”: null, “logicalOperator”: “AND”, “intervalCriteria”: }], “logicalOperator”: “AND”}, {“isEmpty”: false, “elements”: [{“_type”: “intervals”, “include”: true, “protected”: false, “intervalName”: “sut.follower_vehicle”, “intervalType”: “MatchIntervalData”, “scenarioName”: null, “childrenFilter”: null, “logicalOperator”: “AND”, “intervalCriteria”: }], “logicalOperator”: “AND”}], “customTimeRelation”: null}}, “creationContext”: “WORKSPACE”, “lastModifiedById”: “42e6b2f773365a5fae82”}}’, $2 = ‘2’, $3 = ‘4a4cafc9c441ec77fc08’, $4 = ‘4643b0cbd1fce539cc70’

what can be the cause of this

Hibernate ORM just passes the object to serialize to Jackson or JSONB, so it is up to the implementation that you are using to retain the order.
Since you’re using Spring, I guess that you are using Jackson. A quick google search shows that Jackson by default does not retain the order of maps, but there are features that you can enable to control this behavior: Serialization features · FasterXML/jackson-databind Wiki · GitHub

Hi,

but I am using LInkedHashMap for which the order is pereserved.

also, if you will look at the hibernat debug print i added it can be seen that the order is correct in the “Release” message. and jackson is being used there as well.

in the postgres log however, i can see that the order is different.

can you point me to the exact location in code in which the conversion happens to the exact value which is being sent to postgres ? i can try to debug the compiled code.

org.hibernate.type.format.jackson.JacksonJsonFormatMapper#toString is the method that you want to debug.

thanks for the reply, so i debugged this path and it seems that the values produced by the mapper are correct. however , still, when looking at the postgress log itself, the json string shows different order
this is the stack trace. is there a different location worth exploring

I don’t know how PostgreSQL does its things, but if you serialize a LinkedHashMap to a JSON object, then I’m not sure if the order of the attributes can be fully ensured. Maybe deserialization messes things up?

I had a discussion with Postgres RnD who claimed

“The only way this could possibly be happening on the Postgres server
side is if the value is being cast to jsonb on its way to the table.
I’d check issues like how the prepared statement’s parameter is
declared.

If you are going through JDBC or another client-side stack, it’s
possible that that is substituting jsonb for json in the command
issued to the server. You might need to pretend that the parameter
is plain text to avoid that.

I defined the relevant field as follows in code so the above looks strange to me

@JdbcTypeCode(SqlTypes.JSON)
@Column(name = “compound_rules”, columnDefinition = “json”)
var compoundRules: LinkedHashMap<String, CompoundRuleData>,

so the definition seems correct ?

where in the hibernate code can i put a breakpoint on to see how the type is being interpreted ?

Hi,
so probably the culprit is here:

please note that the column definition is json

@JdbcTypeCode(SqlTypes.JSON)
@Column(name = “compound_rules”, columnDefinition = “json”)
var compoundRules: LinkedHashMap<String, CompoundRuleData>,

however, for some reason the PGObject type is noted as jsonb in the hilghted line
in the picture below

what can cause this ?

This is correct, by default, Hibernate ORM wants to store data as jsonb. The json data type is only chosen if the database does not support jsonb. You could theoretically change the behavior by creating a custom TypeContributor (which is loaded through the Java ServiceLoader mechanism) and overwrite the JdbcType descriptors with the variants for the json DDL type.

  • jdbcTypeRegistry.addDescriptor(new org.hibernate.dialect.type.PostgreSQLJsonPGObjectJsonType())
  • jdbcTypeRegistry.addTypeConstructor(new org.hibernate.dialect.type.PostgreSQLJsonArrayPGObjectJsonJdbcTypeConstructor())

Hi. looking at it again

the column itself is defined with the following annotation
@ColumnColumn(columnDefinition = “json”) *
shouldn’t it be suffice even if the database support jsonb ?
also, in the database itself,the column is defined as a json*

on top of that and based on your example, if going with your direction, a type constructor is not enough right ?. a JdbcType should also be definded right ? or is there a built in thing for this already in hibernate ?

Hibernate ORM might need to “cast” to a JSON type in various places and it’s not always possible to infer the desired DDL type via column references, so no, it’s not “enough” to specify a columnDefinition. In fact, you should avoid that altogether and probably figure out a way to tell Jackson to serialize your linked hash map to an array instead of a JSON object, since the order of properties in a JSON object should not matter.

jackson serializer maintain order of linkedhash map as in arrays. this is by design assuming you go for json and not jsonb which is what we are trying to do. and i can not really move away from that due to backward competability issues

i was trying to follow your proposal above and wrote the following code, but it doesn’t seems that it is being used by hibernate although it is loaded properly by the service loader. any insights ?

ass JsonJdbcType : JdbcType {

    private val objectMapper = ObjectMapper().apply {
        registerModule(JavaTimeModule())
        registerKotlinModule()
        disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
        configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, false)
        configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
        setVisibility(PropertyAccessor.GETTER, JsonAutoDetect.Visibility.NONE);
        setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
    }

    override fun getJdbcTypeCode(): Int = Types.OTHER

    override fun <X> getBinder(javaType: JavaType<X>): ValueBinder<X> {
        return object : BasicBinder<X>(javaType, this) {
            override fun doBind(st: PreparedStatement, value: X, index: Int, options: WrapperOptions) {
                val pgObject = PGobject().apply {
                    type = "json"  // Use "json" not "jsonb"
                    this.value = objectMapper.writeValueAsString(value)
                }
                st.setObject(index, pgObject)
            }

            override fun doBind(st: CallableStatement, value: X, name: String, options: WrapperOptions) {
                val pgObject = PGobject().apply {
                    type = "json"
                    this.value = objectMapper.writeValueAsString(value)
                }
                st.setObject(name, pgObject)
            }
        }
    }

    override fun <X> getExtractor(javaType: JavaType<X>): ValueExtractor<X> {
        return object : BasicExtractor<X>(javaType, this) {
            override fun doExtract(rs: ResultSet, paramIndex: Int, options: WrapperOptions): X? {
                val jsonString = rs.getString(paramIndex)
                return if (jsonString == null) null
                else objectMapper.readValue(jsonString, javaType.javaTypeClass)
            }

            override fun doExtract(statement: CallableStatement, index: Int, options: WrapperOptions): X? {
                val jsonString = statement.getString(index)
                return if (jsonString == null) null
                else objectMapper.readValue(jsonString, javaType.javaTypeClass)
            }

            override fun doExtract(statement: CallableStatement, name: String, options: WrapperOptions): X? {
                val jsonString = statement.getString(name)
                return if (jsonString == null) null
                else objectMapper.readValue(jsonString, javaType.javaTypeClass)
            }
        }
    }
}
class JsonJdbcTypeConstructor : JdbcTypeConstructor {

    override fun getDefaultSqlTypeCode(): Int = SqlTypes.JSON


    override fun resolveType(
        typeConfiguration: TypeConfiguration,
        dialect: Dialect,
        elementType: BasicType<*>,
        columnTypeInformation: ColumnTypeInformation?
    ): JdbcType {
        return JsonJdbcType() // Your custom implementation
    }

    override fun resolveType(
        typeConfiguration: TypeConfiguration,
        dialect: Dialect,
        elementType: JdbcType,
        columnTypeInformation: ColumnTypeInformation?
    ): JdbcType {
        return JsonJdbcType()
    }
}
class CustomTypeContributor : TypeContributor {
    override fun contribute(typeContributions: TypeContributions, serviceRegistry: ServiceRegistry?) {
        // Get the JDBC type registry
        val jdbcTypeRegistry = typeContributions
            .getTypeConfiguration()
            .getJdbcTypeRegistry()


        // Register a custom type constructor
        // Example: 
        jdbcTypeRegistry.addTypeConstructor(
            SqlTypes.JSON,  // JDBC type code
            JsonJdbcTypeConstructor() // Your constructor implementation
        )
    }
}

A LinkedHashMap is serialized as JSON object e.g. {"key1": "value1", "key2": "value2"} where the order of properties semantically shouldn’t matter. Maybe you can tell Jackson to serialize this as array instead e.g. [{"key1":"value1"}, {"key2":"value2"}]?

How do you know it is being loaded by the service loader? Is the CustomTypeContributor#contribute method called?

You should IMO register the following two things:

        jdbcTypeRegistry.addDescriptor(
            org.hibernate.dialect.type.PostgreSQLJsonPGObjectJsonType()
        )
        jdbcTypeRegistry.addTypeConstructor(
            org.hibernate.dialect.type.PostgreSQLJsonArrayPGObjectJsonJdbcTypeConstructor()
        )
        org.hibernate.dialect.type.PostgreSQLJsonArrayPGObjectJsonJdbcTypeConstructor()

does not exist for me. in which hibernate version do we have that ?

PostgreSQLJsonArrayPGObjectJsonJdbcTypeConstructor is something that was introduced in Hibernate ORM 7.1+. If you are on 6.6, you can ignore this.