JSONata, Mustache, and data transformation
A piece of text that looks like a “template” may be processed by one of four entirely different engines in Node-RED and Home Assistant. This chapter first identifies the execution environment, then uses fixed, manually supplied input to practise bounded type conversions so the data contract remains predictable before the message reaches the next node.
Why this chapter matters
When all you need is a Celsius-to-Fahrenheit conversion, JSONata in a Change node makes the input and output easier to see than a full JavaScript function. When you need to compose a short line of text for a person to read, Mustache in a Template node is more direct. The problem is that JSONata, Mustache, JavaScript in a Function node, and Home Assistant Jinja all use variables, parentheses, or braces, but they do not share syntax, types, context, or execution locations. Pasting syntax from one environment into another field may not only cause an error, but can also produce a string that appears valid while having the wrong type.
This chapter targets Node-RED 5.0.2 and Home Assistant WebSocket nodes 0.80.3. Start with the least-capable tool that meets the requirement, using the matrix in the next section. If a transformation needs multiple branches, explicit error objects, or complex loops, move to the Function node in Chapter 16 rather than turning JSONata into a hard-to-maintain program.
Distinguish the four execution environments first
First identify which node provides the field, then choose the syntax. Do not call something a template merely because braces appear on the screen. The following table compares where each option runs, its input root, its types, and its escaping behaviour.
| Environment | Where it runs | Primary input and context | Result type | Escaping considerations |
|---|---|---|---|---|
| Node-RED JSONata | In the Node-RED runtime; for example, when a Change or Switch typed input is set to expression | The input root is the entire message, so use payload, not msg.payload; the runtime separately provides $flowContext(), $globalContext(), and $env() | Can return a string, number, Boolean, null, array, or object; it may return no result when no value matches | It is not a text template and does not perform HTML escaping; validate its output against the next node’s data contract |
| Node-RED Mustache | In the core Node-RED Template node | Looks up message fields first and can also retrieve values through explicit flow/global context tokens | Initially renders text; when the Template output format is JSON or YAML, the text is then parsed into the corresponding type | Double braces perform HTML escaping by default; triple braces insert raw, unescaped values and are safe only when the output context is known |
| Function JavaScript | In the Node-RED Function node sandbox | Reads the message through msg and provides the node/flow/global context APIs | Any JavaScript value you create; what you send must be a message object or an array of messages arranged by output | JavaScript does not automatically apply context-specific escaping for HTML, JSON, or notification text; see Safe JavaScript |
| Home Assistant Jinja | The Render Template node sends the template to Home Assistant, where HA renders it | Uses Home Assistant’s template environment, not Node-RED’s msg or flow context | In 0.80.3, the node defines the result as a string and writes it to the configured result location | HA/Jinja applies its own semantics; if the result will be used as JSON, Node-RED must still parse and validate it explicitly |
| Requirement | Preferred tool | Reason | Stop condition |
|---|---|---|---|
| Set, move, or delete one property | Change | The rules are visible in the node settings; no code is required | Add a Switch first if the input shape has not been validated |
| Calculate a new object from a fixed JSON structure | JSONata | Preserves number, Boolean, array, and object types | Split the operation into smaller steps or use a Function node if it scans an unbounded collection or becomes difficult to understand |
| Generate a short notification or explanatory text | Mustache Template | Interpolation semantics are simple, with HTML escaping enabled by default | Choose another tool when inserting a complete object or producing a non-string value |
| Produce multiple outputs, explicit errors, or complex branches | Function JavaScript | Provides clear control flow and message contracts | Do not add external I/O, unbounded loops, or arbitrary modules |
| Use HA template entity/state semantics | HA Jinja through Render Template | Home Assistant runs the template in its own environment | Do not use it when the work must be offline, low-latency, or confined to one system |
| Convert external text into a structure | The corresponding parser node | JSON, CSV, HTML, XML, and YAML each have a core parser | Refuse to parse input until its size and shape are bounded |
The same name does not guarantee the same type in all four environments. For example, a Home Assistant state is often a string; JSONata multiplication is numeric, whereas Mustache only inserts values into text. Guard the transformation with type checks and do not rely on implicit conversion.
Complete one safe transformation with a fixed flow
The example 09-jsonata-transform.json contains only a manual Inject node, a Change node, and a Debug node. The Inject node has no schedule and does not send automatically at startup. Its data is a fixed temperature object, and the entire path has no external I/O.
- Inspect the flow as text after downloading it.
Confirm that the sole input has empty strings for both
repeatandcrontab, and thatonceisfalse. Confirm that there are no server configurations, credentials, URLs, files, or external nodes. Complete this check before importing the flow. - Import it into a separate tab and verify the nodes.
After import, the flow should contain only “Manually Send Temperature Data”, “Convert Temperature Data”, and “View Conversion Data”. The Change rule targets
msg.payload, and its source type is a JSONata expression. If the editor shows different nodes or additional connections, do not deploy; obtain a fresh copy of the pinned file from this site. - Understand the expression before deploying it.
The expression reads
payload.temperaturefrom the top-level message and creates a new object; it does not modify an external system. The fixed expression is:{"Celsius": payload.temperature, "Fahrenheit": payload.temperature * 9 / 5 + 32}For an input of
{"temperature":25}, the expectedmsg.payloadis an object containing 25 degrees Celsius and 77 degrees Fahrenheit, not JSON text. - Deploy the smallest possible scope and trigger it manually once.
This is a pure-data flow, but first make sure its workspace is not connected to any other branch. After deployment, press Inject exactly once and use Debug to inspect the
payloadtype and its two fields. Do not stress-test by triggering Inject many times, and do not leave complete messages visible in Debug for an extended period. - Validate the contract with boundary inputs.
Temporarily change the manual payload to
{"temperature":0}and{"temperature":-10}, and confirm results of 32 and 14 Fahrenheit respectively. With JSONata 2.2.2 in Node-RED 5.0.2, keep the fixed expression{"Celsius": payload.temperature, "Fahrenheit": payload.temperature * 9 / 5 + 32}. Then change the payload to{}; Debug receivesmsg.payloadas an empty object,{}. The expression has returned an object, but omitted both properties because their values are undefined. If the expression contained onlypayload.temperature, the entire expression would instead return no result. In a production flow, a Switch must reject both cases before they can reach an action.
JSONata: message-rooted declarative transformation
JSONata is a functional, declarative language for JSON structures. In a Node-RED typed input, the input document is the top-level message, so payload.temperature is the correct path in this example. Writing msg.payload.temperature looks for a top-level property named msg and will usually not return the value you intended.
A safe transformation should define its output shape explicitly. The following example limits an array to its first 20 entries and retains only allowed fields. It performs no I/O and does not access any settings:
(
$items := payload.items[type = "reading"][[0..19]];
$items.{
"name": $string(name),
"value": $number(value),
"valid": $type(value) = "number"
}
)
Here, [[0..19]] selects an index range. Your data contract should determine the real upper limit; do not treat the number in this example as a universal limit for every system. If the input may be either a singleton or an array, you can use an array constructor to produce a semantically stable output, but you must still test null values and no-result cases separately. When JSONata cannot find a path, it may return “no result”, which is distinct from an explicit null, an empty array, or false.
Additional helpers provided by HA nodes
The JSONata service in Home Assistant WebSocket nodes 0.80.3 adds the following functions only within Home Assistant nodes; the core Change node does not automatically gain them. These helpers read entity, device, and area data currently available to the integration. They cannot be transferred for use in Mustache, Function JavaScript, or HA Jinja.
| Helper | Exact use in 0.80.3 | Usage boundary |
|---|---|---|
$entity() | Returns the entity object that triggered the current node | Not every node or event has a current entity; handle undefined first |
$prevEntity() | Allows an event node to retrieve the previous state entity | Do not assume it exists during initialisation, creation, or deletion |
$entities()/$entities(entity_id) | Returns every cached entity, or one entity selected by entity ID | The full collection may be large; use an environment placeholder for a single ID, and do not put a real ID in the tutorial or logs |
$areas(lookup) | Returns areas when called without an argument; lookup can be an area, entity, or device ID | Areas and IDs are environment-specific data, and a result may not exist |
$areaDevices(areaId)/$areaEntities(areaId) | Lists devices or entities associated with the specified area | Constrain the area first, then limit the result count so each message does not scan a large collection |
$device(lookup)/$deviceEntities(device_id) | Finds a device by entity ID or device name, or returns entities associated with a device | Names may change or be duplicated; do not include real identifiers in a shareable flow |
$outputData(name) | Returns additional output data passed to the HA node’s JSONata service; omitting name returns the available data set | Available keys depend on the node’s call context; do not assume a fixed shape |
$sampleSize(collection,n)/$randomNumber(lower,upper,floating) | Lodash sampling and random-number helpers exposed in 0.80.3 | Results are non-deterministic; do not use them for security, authorisation, or control decisions that must be reproducible |
The safe flow in this chapter deliberately avoids these helpers because it must work completely offline without an HA connection. If a production flow needs them, first use placeholders to create independent test messages, limit the number of results, and show only selected, non-sensitive fields in Debug.
Mustache: compose strings, not objects
The core Template node in Node-RED 5.0.2 uses Mustache. Double braces look up values in the input message. For example, the following plain-text template reads msg.payload.label and msg.payload.value:
Reading name: {{payload.label}}
Reading result: {{payload.value}}
The rendered result is text. If a value contains & or angle brackets, double braces apply HTML escaping; triple braces disable that protection. Raw interpolation is not a universal “fix garbled text” switch. When the output goes to HTML, raw interpolation may cause untrusted content to be treated as markup. When the output will become JSON, do not rely on string concatenation to handle quotation marks, backslashes, and newlines. Use JSONata to create an object directly, or have the Template node’s JSON output parse a fixed template and then validate the resulting type.
A Template node can write its result to a msg, flow, or global location. In addition to explicit flow/global context tokens, Mustache lookup can read an environment value through an env.NAME token. When the node’s configured template is an empty string, version 5.0.2 uses the incoming msg.template instead. Do not let an untrusted upstream source control this dynamic template: it can select renderable environment and context values. Likewise, do not place secrets or credentials in an environment or context that the template can read. This chapter uses fixed templates and reads neither environment nor context.
That does not make context the template’s private storage. If a value persists across messages—or, depending on the store, across restarts—define its lifecycle and cleanup rules first. See Chapter 7, Time and Context. JSONata’s $flowContext(), $globalContext(), and $env() follow the same secret-handling boundaries; the fixed expression in this chapter does not use them.
The boundary between Function JavaScript and HA Jinja
JavaScript in a Function node suits transformations that need explicit control flow, reusable local functions, multiple outputs, or consistent error objects. A Function node receives msg and must send a message object; JSONata treats the whole message as a document and returns the expression result. If a JSONata expression has accumulated layers of variables, hard-to-bound array expansion, and multiple exceptional cases, move it to a Function node, while still keeping the operation purely data-oriented.
In Home Assistant WebSocket nodes 0.80.3, the persisted type of Render Template is api-render-template. It sends a Jinja template to Home Assistant’s HTTP render-template capability, waits for HA to return a string, and writes that string to the configured result location. This request crosses the Node-RED/HA boundary; it is neither local Mustache nor JSONata.
Version 0.80.3 gives message fields priority for all five inputs: msg.template, msg.resultsLocation, msg.resultsLocationType, msg.templateLocation, and msg.templateLocationType. The node does not provide Block Input Overrides. An untrusted upstream source could therefore change the template, the location that stores the original template, the rendered-result location, and its msg/flow/global type. Before a message enters this node, delete all five fields or rebuild them from an allowlist of permitted values. In particular, never allow upstream input to select an arbitrary flow/global write destination. This chapter does not run Render Template, and its fixed example supplies neither a dynamic template nor any renderable secrets.
Bound types, parsing, and performance
A data contract specifies at least four things: permitted input types, required fields, the maximum collection or text size, and the failure path. Checking only that “a payload exists” is not enough, because the payload may be a string, Buffer, array, or deeply nested object. Use a Switch node to separate the paths first, then pass only valid messages to the transformation. For invalid messages, create only a minimal error summary; do not send the complete original content to Debug.
{
"input": {"temperature": 25},
"contract": {
"temperatureType": "number",
"minimum": -50,
"maximum": 100
},
"output": {"Celsius": 25, "Fahrenheit": 77}
}
This is offline test data, not a flow configuration. In a production flow, record the contract in a Comment node, test instructions, or a version-controlled file. If the original data is a JSON string, first limit its size, then use a JSON parser to convert it to an object, and finally validate its fields. Apply the same process to CSV, HTML, XML, and YAML. A parser can recognise a format, but it cannot decide whether the content is reasonable for your use case, nor will it automatically limit nesting depth, column count, or sequence length.
- Limit input: Select only the required range before passing a large array into JSONata; avoid descendant-wildcard scans at an unknown depth.
- Limit output: Create only the fields required downstream; do not copy the entire HA cache or input object.
- Limit frequency: Repeatedly sorting, grouping, or aggregating a large collection for every high-frequency event consumes runtime resources. Throttle first, or run the operation only when the value actually changes.
- Limit observation: Configure Debug to show specific fields and disable it when finished. Large volumes of complete messages add serialisation and sidebar overhead.
- Maintain determinism: Avoid sampling and random numbers in control decisions. The same fixed input should produce the same output so the behaviour can be reproduced.
A safe transformation is not complete merely because “the expression did not report an error”. Its output must satisfy the contract, and the next node must be able to reject invalid data. If the next step may have external side effects, review the input-override and target boundaries in Chapter 11, Action Node. Never treat a successful transformation as sufficient reason to execute an action.
Troubleshooting
- JSONata shows no result: First confirm that the root path is
payload, notmsg.payload. Then use a fixed Inject message to check the field spelling and array index. Test “no result”,null, an empty array, andfalseseparately; do not collapse them into one falsy check. - A calculation produces a string or NaN: Check the Inject node’s payloadType and the actual type shown by Debug. A Home Assistant state is often a string, but this chapter’s offline example must supply a number. When conversion is required, use
$number()explicitly, after confirming that the value falls within the permitted range. - Mustache displays an HTML entity: This is usually the default escaping applied by double braces, not data corruption. First confirm the output context. For plain text, accept the escaping or adjust how the result is displayed. For HTML, do not switch to raw interpolation merely for appearance. Use JSONata instead when the output must be an object.
- JSON/YAML Template parsing fails: Do not insert unescaped dynamic text directly. Reduce the problem to a fixed template and fixed test value, then check quotation marks, line breaks, and the output format. If you need a dynamic object, use JSONata to create a typed value directly.
- HA helpers are unavailable in a Change node: These helpers are injected only by the Home Assistant node JSONata service in Home Assistant WebSocket nodes 0.80.3. The core Change node does not provide them. Restructure the flow to use message input without a helper, or use the helper only in a supported HA node field.
- Render Template returns nothing: It requires an HA connection and Home Assistant must execute the Jinja template. First stop retries and any downstream side effects, then verify the connection and Catch path. Do not experiment by substituting Mustache or JSONata syntax. Continue detailed diagnosis in Chapter 18, Debugging and Testing.
- A transformation causes noticeable delays: Disable the high-frequency input and reproduce the issue with a small, fixed data set. Measure the array length and determine whether the expression sorts data or scans every descendant; then constrain the data before transforming it. Do not trigger increasingly large test sets over and over.
Pinned sources and official documentation
This chapter’s technical boundaries are based on the two exact commits approved for Chapter 15; official documentation supplements them for general concepts. If rolling documentation differs from the pinned version, the behaviour in the pinned source code takes precedence.
- Exact Node-RED 5.0.2 commit: core Change, Switch, Template, Function, and parser implementations.
- Exact Home Assistant WebSocket nodes 0.80.3 commit: JSONataService helpers and Render Template execution boundaries.
- Official Node-RED documentation: Messages.
- Official Node-RED documentation: Context.
- Official Home Assistant WebSocket documentation: JSONata.
- Official Home Assistant documentation: Templating.
FAQ
Why not use msg.payload in JSONata?
payload directly. JavaScript in a Function node accesses the same field as msg.payload.