Looking for a guide?
Check out our JSON best practice guide for examples, advanced features and considerations for using the JSON type.
JSON type stores JavaScript Object Notation (JSON) documents in a single column.
In ClickHouse Open-Source JSON data type is marked as production ready in version 25.3. It’s not recommended to use this type in production in previous versions.
JSON type, you can use the following syntax:
When to use the JSON Type
The JSON type is designed for querying, filtering, and aggregating specific fields within JSON objects that have dynamic or unpredictable structures. It achieves this by splitting JSON objects into separate sub-columns, which dramatically reduces data read and speeds up queries on selected fields compared to alternatives like Map or parsing strings.
However, this comes with important trade-offs:
- Slower
INSERTs - Splitting JSON into sub-columns, performing type inference, and managing flexible storage structures makes inserts slower compared to storing JSON as a simpleStringcolumn. - Slower when reading entire objects - If you need to retrieve complete JSON documents (rather than specific fields), the
JSONtype is slower than reading from aStringcolumn. The overhead of reconstructing objects from separate sub-columns provides no benefit when you’re not doing field-level queries. - Storage overhead - Maintaining separate sub-columns adds structural overhead compared to storing JSON as a single string value.
Use the JSON type when:
- Your data has a dynamic or unpredictable structure with varying keys across documents
- Field types or schemas change over time or vary between records
- You need to query, filter, or aggregate on specific paths within JSON objects whose structure you can’t predict upfront
- Your use case involves semi-structured data like logs, events, or user-generated content with inconsistent schemas
Use a String column (or structured types) when:
- Your data structure is known and consistent - in this case, use normal columns,
Tuple,Array,Dynamic, orVarianttypes instead JSONdocuments are treated as opaque blobs that are only stored and retrieved in their entirety without field-level analysis- You don’t need to query or filter on individual JSON fields within the database
- The
JSONis simply a transport/storage format, not analyzed within ClickHouse
Creating JSON
In this section we’ll take a look at the various ways that you can create JSON.
Using JSON in a table column definition
Query (Example 1)
Response (Example 1)
Query (Example 2)
Response (Example 2)
Using CAST with ::JSON
It is possible to cast various types using the special syntax ::JSON.
CAST from String to JSON
Query
Response
CAST from Tuple to JSON
Query
Response
CAST from Map to JSON
Query
Response
JSON paths are stored flattened. This means that when a JSON object is formatted from a path like will return:and not:
a.b.c
it is not possible to know whether the object should be constructed as { "a.b.c" : ... } or { "a": { "b": { "c": ... } } }.
Our implementation will always assume the latter.For example:Query
Response
Reading JSON paths as sub-columns
TheJSON type supports reading every path as a separate sub-column.
If the type of the requested path is not specified in the JSON type declaration,
then the sub column of the path will always have type Dynamic.
For example:
Query
Response
Query (Reading JSON paths as sub-columns)
Response (Reading JSON paths as sub-columns)
getSubcolumn function to read subcolumns from JSON type:
Query
Response
NULL values:
Query
Response
Query
Response
a.b, the type is UInt32 as we specified it to be in the JSON type declaration,
and for all other sub-columns the type is Dynamic.
It is also possible to read sub-columns of a Dynamic type using the special syntax json.some.path.:TypeName:
Query
Response
Dynamic sub-columns can be cast to any data type. In this case an exception will be thrown if the internal type inside Dynamic cannot be cast to the requested type:
Query
Response
Query
Response
To read subcolumns efficiently from Compact MergeTree parts make sure MergeTree setting write_marks_for_substreams_in_compact_parts is enabled.
Reading JSON sub-objects as sub-columns
TheJSON type supports reading nested objects as sub-columns with type JSON using the special syntax json.^some.path:
Query
Response
Query
Response
When paths are stored in basic (
map) shared data, reading sub-object sub-columns may be inefficient as it requires scanning the entire shared data structure. With map_with_buckets or advanced shared data serialization, reading sub-columns from shared data is highly optimized.Reading JSON combined sub-columns
TheJSON type supports reading a path as a combined sub-column using the special syntax json.@some.path.
A combined sub-column for a given path returns:
- The literal value stored at that path as
Dynamic, if the path has a literal value. - A JSON sub-object at that path as
Dynamic, if the path has no literal value but has nested sub-paths. NULL, if neither a literal value nor any sub-paths exist for that path.
json.a) and the sub-object sub-column (json.^a).
The following example compares all three sub-column types for path a:
Query
Response
Query
Response
- Row 1:
aholds a literal42.json.areturns it asDynamic(Int64),json.^areturns an empty sub-object{}(no nested keys undera), andjson.@areturns the literal42. - Row 2:
aholds a nested object.json.areturnsNULL(no literal at that path),json.^areturns the sub-object asJSON, andjson.@aalso returns the sub-object asDynamic(JSON). - Row 3:
ais absent entirely. Bothjson.aandjson.@areturnNULL, whilejson.^areturns an empty{}.
When paths are stored in basic (
map) shared data, reading combined sub-columns may be inefficient as it requires scanning the entire shared data structure. With map_with_buckets or advanced shared data serialization, reading sub-columns from shared data is highly optimized.Type inference for paths
During parsing ofJSON, ClickHouse tries to detect the most appropriate data type for each JSON path.
It works similarly to automatic schema inference from input data,
and is controlled by the same settings:
- input_format_try_infer_dates
- input_format_try_infer_datetimes
- schema_inference_make_columns_nullable
- input_format_json_try_infer_numbers_from_strings
- input_format_json_infer_incomplete_types_as_strings
- input_format_json_read_numbers_as_strings
- input_format_json_read_bools_as_strings
- input_format_json_read_bools_as_numbers
- input_format_json_read_arrays_as_strings
- input_format_json_infer_array_of_dynamic_from_array_of_different_types
Query
Response
Query
Response
Query
Response
Query
Response
Handling arrays of JSON objects
JSON paths that contain an array of objects are parsed as typeArray(JSON) and inserted into a Dynamic column for the path.
To read an array of objects, you can extract it from the Dynamic column as a sub-column:
Query
Response
Query
Response
max_dynamic_types/max_dynamic_paths parameters of the nested JSON type got reduced compared to the default values.
This is needed to avoid the number of sub-columns growing uncontrollably on nested arrays of JSON objects.
Let’s try to read sub-columns from a nested JSON column:
Query
Response
Array(JSON) sub-column names using a special syntax:
Query
Response
[] after the path indicates the array level. For example, json.path[][] will be transformed to json.path.:Array(Array(JSON))
Let’s check the paths and types inside our Array(JSON):
Query
Response
Array(JSON) column:
Query
Response
JSON column:
Query
Response
Handling JSON keys with NULL
In our JSON implementationnull and absence of the value are considered equivalent:
Query
Response
Handling JSON keys with dots
Internally JSON column stores all paths and values in a flattened form. It means that by default these 2 objects are considered as the same:a.b and value 42. During formatting of JSON we always form nested objects based on the path parts separated by dot:
Query
Response
{"a.b" : 42} is now formatted as {"a" : {"b" : 42}}.
This limitation also leads to the failure of parsing valid JSON objects like this:
Query
Response
25.8). In this case during parsing all dots in JSON keys will be
escaped into %2E and unescaped back during formatting.
Query
Response
Query
Response
Query
Response
json.`a.b` is equivalent to subcolumn json.a.b and won’t read path with escaped dot:
Query
Response
SKIP/SKIP REGEX sections), you have to use escaped dots in the hint:
Query
Response
Query
Response
Reading JSON type from data
All text formats (JSONEachRow,
TSV,
CSV,
CustomSeparated,
Values, etc.) support reading the JSON type.
Examples:
Query
Response
CSV/TSV/etc, JSON is parsed from a string containing the JSON object:
Query
Response
Reaching the limit of dynamic paths inside JSON
TheJSON data type can store only a limited number of paths as separate sub-columns internally.
By default, this limit is 1024, but you can change it in the type declaration using parameter max_dynamic_paths.
When the limit is reached, all new paths inserted to a JSON column will be stored in a single shared data structure.
It’s still possible to read such paths as sub-columns,
but it might be less efficient (see section about shared data).
This limit is needed to avoid having an enormous number of different sub-columns that can make the table unusable.
Let’s see what happens when the limit is reached in a few different scenarios.
Reaching the limit during data parsing
During parsing ofJSON objects from data, when the limit is reached for the current block of data,
all new paths will be stored in a shared data structure. We can use the following two introspection functions JSONDynamicPaths, JSONSharedDataPaths:
Query
Response
e and f.g the limit was reached,
and they got inserted into a shared data structure.
During merges of data parts in MergeTree table engines
During a merge of several data parts in aMergeTree table the JSON column in the resulting data part can reach the limit of dynamic paths
and won’t be able to store all paths from source parts as sub-columns.
In this case, ClickHouse chooses what paths will remain as sub-columns after merge and what paths will be stored in the shared data structure.
In most cases, ClickHouse tries to keep paths that contain
the largest number of non-null values and move the rarest paths to the shared data structure. This does, however, depend on the implementation.
Let’s see an example of such a merge.
First, let’s create a table with a JSON column, set the limit of dynamic paths to 3 and then insert values with 5 different paths:
Query
JSON column containing a single path:
Query
Response
Query
Response
a, b and c and moved paths d and e to a shared data structure.
Shared data structure
As was described in the previous section, when themax_dynamic_paths limit is reached all new paths are stored in a single shared data structure.
In this section we will look into the details of the shared data structure and how we read paths sub-columns from it.
See section “introspection functions” for details of functions used for inspecting the contents of a JSON column.
Shared data structure in memory
In memory, shared data structure is just a sub-column with typeMap(String, String) that stores mapping from a flattened JSON path to a binary encoded value.
To extract a path subcolumn from it, we just iterate over all rows in this Map column and try to find the requested path and its values.
Shared data structure in MergeTree parts
In MergeTree tables we store data in data parts that stores everything on disk (local or remote). And data on disk can be stored in a different way compared to memory. Currently, there are 3 different shared data structure serializations in MergeTree data parts:map, map_with_buckets
and advanced.
The serialization version is controlled by MergeTree
settings object_shared_data_serialization_version
and object_shared_data_serialization_version_for_zero_level_parts
(zero level part is the part created during inserting data into the table, during merges parts have higher level).
Note: changing shared data structure serialization is supported only
for v3 object serialization version
Map
Inmap serialization version shared data is serialized as a single column with type Map(String, String) the same as it’s stored in
memory. To read path sub-column from this type of serialization ClickHouse reads the whole Map column and
extracts the requested path in memory.
This serialization is efficient for writing data and reading the whole JSON column, but it’s not efficient for reading paths sub-columns.
Map with buckets
Inmap_with_buckets serialization version shared data is serialized as N columns (“buckets”) with type Map(String, String).
Each such bucket contains only subset of paths. To read path sub-column from this type of serialization ClickHouse
reads the whole Map column from a single bucket and extracts the requested path in memory.
This serialization is less efficient for writing data and reading the whole JSON column, but it’s more efficient for reading paths sub-columns
because it reads data only from required buckets.
Number of buckets N is controlled by MergeTree settings object_shared_data_buckets_for_compact_part (8 by default)
and object_shared_data_buckets_for_wide_part (32 by default).
The maximum allowed value for both settings is 256.
Advanced
Inadvanced serialization version shared data is serialized in a special data structure that maximizes the performance
of paths sub-columns reading by storing some additional information that allows to read only the data of requested paths.
This serialization also supports buckets, so each bucket contains only sub-set of paths.
This serialization is quite inefficient for writing data (so it’s not recommended to use this serialization for zero-level parts), reading the whole JSON column is slightly less efficient compared to map serialization, but it’s very efficient for reading paths sub-columns.
Note: because of storing some additional information inside the data structure, the disk storage size is higher with this serialization compared to
map and map_with_buckets serializations.
For more detailed overview of the new shared data serializations and implementation details read the blog post.
Controlling the number of dynamic paths inside JSON in MergeTree parts
The main way to set a limit on dynamic paths in JSON is to usemax_dynamic_paths parameter inside the JSON type declaration.
But changing max_dynamic_paths for existing columns requires running ALTER TABLE <table> MODIFY COLUMN <column> JSON(max_dynamic_paths=K) that will start a background mutation that will rewrite all existing parts.
Such mutation can be really heavy and can affect the server performance until the mutation is finished. To avoid this, you can use these 3 settings that can help you to change the limit on dynamic paths in MergeTree tables for new data parts:
merge_max_dynamic_subcolumns_in_wide_part- a MergeTree setting that limits the number of dynamic subcolumns for each JSON column during merge into a Wide data part.merge_max_dynamic_subcolumns_in_compact_part- a MergeTree setting that limits the number of dynamic subcolumns for each JSON column during merge into a Compact data part.max_dynamic_subcolumns_in_json_type_parsing- a session setting that limits the number of dynamic subcolumns for each JSON column during parsing of JSON data into a JSON column.
max_dynamic_paths parameter, even if values of described settings are higher.
Introspection functions
There are several functions that can help to inspect the content of the JSON column:JSONAllPathsJSONAllPathsWithTypesJSONAllValuesJSONDynamicPathsJSONDynamicPathsWithTypesJSONSharedDataPathsJSONSharedDataPathsWithTypesdistinctDynamicTypesdistinctJSONPaths and distinctJSONPathsAndTypes
2020-01-01:
Query
Response
Query
Response
ALTER MODIFY COLUMN to JSON type
It’s possible to alter an existing table and change the type of the column to the newJSON type. Right now only ALTER from a String type is supported.
Example
Query
Response
Lazy Type Hints (Experimental)
This feature is experimental and requires the setting
allow_experimental_json_lazy_type_hints to be enabled.ALTER TABLE ... MODIFY COLUMN, ClickHouse normally rewrites all data parts to materialize the new type hints. For tables with large amounts of historical data (hundreds of terabytes), this can be extremely expensive.
Lazy type hints allow adding type hints as a metadata-only operation without rewriting existing data:
- Old parts: Type hints are applied at query time by casting from
Dynamicto the hinted type - New parts: Type hints are materialized during
INSERToperations - Merges: Type hints are materialized when parts are merged
Enabling Lazy Type Hints
Example
Query
Response
Verifying No Mutation Occurred
You can verify that theALTER completed without a mutation by checking the system.mutations table:
Materializing Type Hints
To materialize type hints in existing data, you can either:- Wait for background merges: ClickHouse will automatically materialize type hints when parts are merged
- Force merge: Use
OPTIMIZE TABLE test_lazy FINALto merge all parts immediately - Rewrite parts: Use
ALTER TABLE test_lazy REWRITE PARTSto rewrite parts with the new metadata
Limitations
- This feature is experimental and may change in future versions
- Query-time type conversion can have significant performance overhead compared to pre-materialized types, especially for large JSON objects
- The feature only applies when modifying
typed_paths(type hints); other JSON parameters likemax_dynamic_paths,SKIP, orSKIP REGEXPstill require mutations
Comparison between values of the JSON type
JSON objects are compared similarly to Maps. For example:Query
Response
Variant data type.
Data skipping indexes for JSON
Data skipping indexes can be used withJSON columns in three ways:
- Indexes on specific subcolumns — create a standard skip index on a known JSON path, just like on a regular column. This indexes the values at that path.
- Path-based indexes with
JSONAllPaths— index the set of paths present in each granule to skip granules that cannot contain the queried path. - Value-based indexes with
JSONAllValues— index all values across all JSON paths using a text index to accelerate full-text search on any JSON subcolumn with a single index.
Indexes on specific subcolumns
You can create a skip index on any JSON subcolumn using the same syntax as for regular columns. Any supported index type works (minmax, set, bloom_filter, tokenbf_v1, ngrambf_v1, etc.).
There are two ways to reference a JSON subcolumn in an index expression:
- Typed path declared in the JSON type hint — access by name directly:
json.a. - Dynamic path with explicit cast — use the
::cast syntax:json.b::String.
json.a || json.b::String.
Example
Query
minmax index on the typed subcolumn data.sensor_id narrows the scan to matching granules:
Query
Response
bloom_filter index on the cast subcolumn data.location::String also works:
Query
Response
Path-based indexes with JSONAllPaths
Data skipping indexes can also be created onJSON columns using the JSONAllPaths function.
This works similarly to creating skip indexes on Map columns via mapKeys — the index stores the set of JSON paths present in each granule and uses it to skip granules that cannot contain the queried path.
Supported index types
JSONAllPaths can be used with the following skip index types:
bloom_filter— supportsequals,in, andIS NOT NULL.tokenbf_v1— supportsequalsandIS NOT NULL.ngrambf_v1— supportsequalsandIS NOT NULL.text(inverted index) — supportsequals,inandIS NOT NULL.
Example
Query
EXPLAIN indexes = 1 to verify that the skip index is being used. When a path exists only in one part, the index skips the other part:
Query
Response
Query
Response
IS NOT NULL also uses the index — it skips granules where the path is absent (since the value would be NULL):
Query
Response
How it works
TheJSONAllPaths(json_column) expression produces an Array(String) containing all paths present in a JSON value.
The skip index stores these path strings in its data structure (bloom filter or inverted index).
When a query filters on json.some.path, the index checks whether the string "some.path" is present in the index for each granule and skips granules where it is absent.
Safety with missing paths
When a JSON path is absent from a granule, the subcolumn evaluates to:NULLforDynamictype (e.g.,json.path) andNullabletyped subcolumns (e.g.,json.path.:Int64) — comparisons withNULLalways return false, so skipping is safe.- The type’s default value for non-
NullableCAST expressions (e.g.,json.path::Int64produces0when the path is missing) — skipping is safe only when the compared value differs from the default. The index automatically handles this distinction.
Full-text search with JSONAllValues
Text indexes can be used to accelerate full-text search on JSON columns via theJSONAllValues function.
JSONAllValues returns all values from a JSON column as Array(String), which can be indexed by a text index.
A single index on JSONAllValues(json_column) covers all JSON paths, enabling full-text search on any subcolumn without creating separate indexes for each path.
See Value-based indexes with JSONAllValues in the text indexes documentation for details and examples.
Tips for better usage of the JSON type
Before creatingJSON column and loading data into it, consider the following tips:
- Investigate your data and specify as many path hints with types as you can. It will make storage and reading much more efficient.
- Think about what paths you will need and what paths you will never need. Specify paths that you won’t need in the
SKIPsection, andSKIP REGEXPsection if needed. This will improve the storage. - Don’t set the
max_dynamic_pathsparameter to very high values, as it can make storage and reading less efficient. While highly dependent on system parameters such as memory, CPU, etc., a general rule of thumb would be to not setmax_dynamic_pathsgreater than 10 000 for the local filesystem storage and 1024 for the remote filesystem storage.