JSON Root Elements and Document Structure
Understanding JSON Document Organisation in MarkLogic
The examples in this article use the llamaverse (v2.0+). The llamaverse sample data is freely available from github.com/cleverllamas/llamaverse — see the llamaverse article for full setup instructions.
JSON looks simple. That simplicity is part of the problem. Teams moving from XML to MarkLogic assume the structural rules are broadly similar. They are not. XML has exactly one named root element. JSON does not. This difference affects root queries, Template Driven Extraction, document routing, type identification, and how cleanly indexes align with documents.
Understanding how MarkLogic represents JSON internally — and what "root" means in that model — is the foundation for making good JSON document design decisions.
The First Structural Difference: XML Root vs JSON Top-Level Properties
An XML document always has exactly one root element. A JSON document does not have a named root element in the XML sense. Instead, MarkLogic represents JSON as a node tree with an anonymous container at the top. If the JSON document begins with an object, the top node is an object-node(). If it begins with an array, the top node is an array-node(). Under that anonymous container, object properties become the navigable structure.
This means there are three distinct patterns for the top level of a JSON document, all valid:
Flat — all fields at the top level, no outer wrapper:
{
"id": "llama-001",
"name": "Marisol",
"breed": "Suri"
}
Single-root — all content under one named outer property:
{
"llama": {
"id": "llama-001",
"name": "Marisol",
"breed": "Suri"
}
}
Multi-root — multiple distinct top-level properties:
{
"llama": {
"name": "Marisol",
"breed": "Suri"
},
"metadata": {
"source": "field-survey",
"capturedAt": "2024-01-15T09:30:00Z"
}
}
The llamaverse llama documents use the flat pattern — fields such as id, name, heightCm, and breed appear directly at the top level with no wrapper. This is a common JSON shape and perfectly valid. The choice of shape has specific consequences for how MarkLogic indexes and queries the documents.
How MarkLogic Represents JSON Internally
MarkLogic exposes JSON as nodes, which means JSON content participates in query and extraction APIs in a structured way.
| Node type | Meaning | Example |
|---|---|---|
object-node() | A JSON object | { "name": "Marisol" } |
array-node() | A JSON array | ["Mari", "Sol"] |
number-node() | A JSON number | 149 |
boolean-node() | A JSON boolean | true |
null-node() | A JSON null | null |
| String property value | A string under a named property | "Suri" |
The anonymous top-level container means that property names — not a root element name — form the first level of the navigable structure. This is why cts:document-root-query() behaves very differently for JSON than for XML.
cts:document-root-query() — The JSON Behaviour
For XML, cts:document-root-query(xs:QName("llama")) means exactly one thing: match documents whose root element is <llama>. For a well-formed XML document, exactly one root element name can match.
For JSON, the query matches documents that have the named property at the top level. This is not exclusive. A multi-root document with both llama and metadata at the top level will match root queries for both names.
The following example inserts the three document shapes and demonstrates this directly:
xquery version "1.0-ml";
(: Insert three example documents into the "examples" collection: :)
(: /cleverllamas/llamaverse/extensions/json-root-elements/flat.json :)
(: /cleverllamas/llamaverse/extensions/json-root-elements/single-root.json :)
(: /cleverllamas/llamaverse/extensions/json-root-elements/multi-root.json :)
xdmp:document-insert("/cleverllamas/llamaverse/extensions/json-root-elements/flat.json",
object-node { "id": "llama-001", "name": "Marisol", "breed": "Suri" },
xdmp:default-permissions(), ("examples"));
xdmp:document-insert("/cleverllamas/llamaverse/extensions/json-root-elements/single-root.json",
object-node { "llama": object-node { "id": "llama-001", "name": "Marisol", "breed": "Suri" } },
xdmp:default-permissions(), ("examples"));
xdmp:document-insert("/cleverllamas/llamaverse/extensions/json-root-elements/multi-root.json",
object-node {
"llama": object-node { "name": "Marisol", "breed": "Suri" },
"metadata": object-node { "source": "field-survey" }
},
xdmp:default-permissions(), ("examples"))
xquery version "1.0-ml";
(: cts:document-root-query matches documents where the named property exists :)
(: at the top level of the JSON document. :)
(: How many documents in "examples" have "llama" as a top-level property? :)
let $llama-count := fn:count(cts:search(fn:collection("examples"), cts:document-root-query(xs:QName("llama"))))
(: How many have "metadata"? :)
let $metadata-count := fn:count(cts:search(fn:collection("examples"), cts:document-root-query(xs:QName("metadata"))))
(: How many have "id"? :)
let $id-count := fn:count(cts:search(fn:collection("examples"), cts:document-root-query(xs:QName("id"))))
return (
"documents with top-level 'llama': " || $llama-count,
"documents with top-level 'metadata': " || $metadata-count,
"documents with top-level 'id': " || $id-count
)
documents with top-level 'llama': 2
documents with top-level 'metadata': 1
documents with top-level 'id': 1
Two documents match cts:document-root-query("llama") — the single-root document and the multi-root document. Only one matches cts:document-root-query("metadata") — the multi-root document. The flat document (with id, name, breed at root) matches cts:document-root-query("id") but not cts:document-root-query("llama").
This is the key difference from XML: for JSON, cts:document-root-query is not a type guarantee. It is a top-level property membership test.
JSON Path Navigation
MarkLogic lets you navigate JSON using path steps directly. Property names appear as steps in the path, and array items are addressed by 1-based position. The syntax feels similar to XPath but operates on JSON semantics — there are no namespace qualifiers.
xquery version "1.0-ml";
(: JSON properties are navigated using path steps directly. :)
(: Array items are addressed by position (1-based). :)
let $doc := object-node {
"llama": object-node {
"name": "Marisol",
"aliases": array-node { "Mari", "Sol" }
}
}
return (
fn:string($doc/llama/name),
fn:string($doc/llama/aliases[1]),
fn:string($doc/llama/aliases[2])
)
Marisol
Mari
Sol
For the flat llamaverse documents, paths start immediately at the property level: $doc/name, $doc/heightCm, $doc/breed. For single-root documents, the path includes the wrapper: $doc/llama/name. This difference matters for TDE context paths.
cts:json-property-scope-query() and Subtree Precision
cts:json-property-scope-query() constrains a sub-query to matches that occur under a specific property subtree. In flat documents this is equivalent to a plain value query, but in multi-root or nested documents it is the mechanism for avoiding false matches from peer or nested properties that share a name.
xquery version "1.0-ml";
(: cts:json-property-scope-query constrains a sub-query to matches that occur :)
(: under a specific property subtree. In a multi-root document, this :)
(: distinguishes between a "name" under "llama" vs a "name" under "metadata". :)
for $doc in cts:search(
fn:collection("wild-llamas"),
cts:json-property-scope-query(
"name",
cts:json-property-value-query("name", "Aaron")
)
)[1 to 3]
return xdmp:node-uri($doc)
/cleverllamas/llamaverse/raw/wild-llamas/llamas/0c8bdb0d-ac62-49b7-ac74-94dbba46efa5.json
The scope constraint is important in document designs where the same property name can appear under multiple top-level objects with different meanings. For example, a document with both llama.name and handler.name would require scope queries to distinguish between the two.
Use Scope Query Sparingly
cts:json-property-scope-query() is resolved at query time, so deeply nested scope-query chains can become expensive and harder to reason about in production.
Avoid building path-like logic by nesting cts:json-property-scope-query() inside another cts:json-property-scope-query() repeatedly. Prefer stable document design, clear collection filters, and index-backed constraints first, then add scope queries only where they provide essential disambiguation.
TDE Context Paths for JSON
Template Driven Extraction uses context paths to identify where in a document's node tree a template row should be extracted from.
For XML, the context /llama selects the root element named llama. For JSON, the context is a path from the anonymous top-level container. The correct context for a flat document (like the llamaverse llama docs) is simply / — the template operates at the top-level container and accesses properties like id, name, and breed directly. For a single-root document wrapped under llama, the correct context is /llama — the template operates on the content of that property.
{
"template": {
"context": "/",
"rows": [{
"schemaName": "main",
"viewName": "llamas",
"columns": [
{ "name": "id", "scalarType": "string", "val": "id" },
{ "name": "name", "scalarType": "string", "val": "name" },
{ "name": "breed", "scalarType": "string", "val": "breed" }
]
}]
}
}
For a single-root document wrapped under llama, the context would be /llama and the column values would reference id, name, and breed relative to that context — exactly the same column definitions.
Setting the context to / for a multi-root document produces the same columns but applies to a broader context that includes all top-level properties. For documents with a consistent single top-level business object, either style works cleanly. For multi-root documents with heterogeneous content, narrower context paths are usually preferable.
The Single-Root Convention — What It Gives You
A single-root JSON convention means each document has exactly one top-level property that identifies the document type. Examples: { "llama": {...} }, { "sanctuary": {...} }, { "vaccination": {...} }. MarkLogic does not require this, but many teams adopt it deliberately because it provides useful structural guarantees.
| Benefit | Why it matters |
|---|---|
| Cleaner TDE context paths | /llama targets the business subtree directly |
| More predictable root queries | cts:document-root-query("llama") behaves closer to XML's exclusive root match |
| Clearer document routing | Ingest pipelines can identify document type from the first key |
| Simpler scope queries | Property name clashes across peer objects are eliminated |
| Easier validation | "Exactly one top-level property" is a simple structural rule to enforce |
These benefits compound over time. None are dramatic on day one. All are meaningful in a large, multi-team codebase.
The flat pattern (like the llamaverse llama documents) is equally valid and entirely reasonable for datasets with a consistent, uniform structure where the document type is already implicit from the collection or URI. The trade-off is not correctness — it is predictability when document type identification matters.
Common Misunderstandings
| Misunderstanding | Reality |
|---|---|
| JSON has one named root element like XML | JSON has an anonymous top container; there is no designated root element name |
cts:document-root-query("llama") means the document only has llama at the top level | It means the document has llama as a top-level property — other properties may also exist |
TDE context / is always wrong for JSON | / is correct when data is at the top level (flat pattern); /llama is correct for single-root wrapped documents |
| Multi-root JSON is bad practice | It is valid; the trade-off is type ambiguity, not incorrectness |
| Arrays need a special indexing model | MarkLogic indexes array values under the containing property; cts:json-property-value-query works on array values naturally |
Practical Design Guidance
When designing stored JSON in MarkLogic, the most important question is: will I need to identify document type by structure alone? If yes, a single-root wrapper makes that reliable. If document type is already encoded in the URI pattern or collection membership, the flat pattern works just as well and avoids unnecessary nesting.
Use cts:document-root-query as a coarse filter, not a type guarantee for JSON. Combine it with collection queries when type exclusivity matters. In multi-root or flat documents, rely on collection membership or URI patterns for type discrimination rather than root property names.
Need Some Help?
Looking for more information on this subject or any other topic related to MarkLogic? Contact Us (info@cleverllamas.com) to find out how we can assist you with consulting or training!
- The First Structural Difference: XML Root vs JSON Top-Level Properties
- How MarkLogic Represents JSON Internally
- cts:document-root-query() — The JSON Behaviour
- cts:json-property-scope-query() and Subtree Precision
- TDE Context Paths for JSON
- The Single-Root Convention — What It Gives You
- Common Misunderstandings
- Practical Design Guidance