Every system you integrate with speaks its own dialect of JSON. One calls it
amount, the next calls it amount_paid and sends it as a string. One sends a
Unix timestamp, another sends ISO 8601. One nests the customer inside the order,
the other sends a customer ID you have to resolve separately.
The usual answer is a mapper per integration. A file, a class, a set of tests. It works fine for the first three. By the tenth you have ten files that do almost the same thing, and every small change from a partner needs a code review and a deploy.
JSONata is worth knowing because it turns that mapping into data. The transformation becomes a string you can store in a database row, edit at runtime, and version like any other record. I have used it in a few places now and it keeps earning its keep, so this is what I wish someone had shown me first.
What it looks like
JSONata is a query and transformation language for JSON. If you know XPath, the
path syntax will feel familiar. If you know jq, the intent is similar but the
syntax is friendlier and it evaluates to JSON rather than a stream.
Start with a document.
{
"order": {
"id": "A-1042",
"placedAt": "2025-11-04T09:12:00Z",
"lines": [
{ "sku": "TSH-01", "qty": 2, "price": 20.00 },
{ "sku": "MUG-04", "qty": 1, "price": 8.50 },
{ "sku": "CAP-02", "qty": 3, "price": 12.00 }
]
}
}
A path expression walks it.
order.lines.sku
["TSH-01", "MUG-04", "CAP-02"]
Notice what happened there. lines is an array, and appending .sku mapped over
it. There is no loop and no map call. Path traversal over an array is implicitly
a map, and that single behavior is most of why JSONata expressions stay short.
Arithmetic works inside the traversal too.
$sum(order.lines.(qty * price))
84.5
The parentheses evaluate qty * price in the context of each line, then $sum
aggregates the result. Filtering uses square brackets with a predicate.
order.lines[qty > 1].sku
["TSH-01", "CAP-02"]
Building a new shape
Path expressions read data. The other half of the language builds objects, and this is where it stops being a query tool and starts being a transformation language.
{
"orderId": order.id,
"total": $sum(order.lines.(qty * price)),
"itemCount": $sum(order.lines.qty),
"skus": order.lines.sku
}
{
"orderId": "A-1042",
"total": 84.5,
"itemCount": 6,
"skus": ["TSH-01", "MUG-04", "CAP-02"]
}
The whole expression is one value. There are no statements, no assignments to build up a result, no return. You describe the output shape and the values that fill it, and that is the program.
Grouping is worth knowing about early, because it replaces a lot of code. Put an object constructor after a path and the key becomes the grouping key.
order.lines{ sku: $sum(qty * price) }
{
"TSH-01": 40,
"MUG-04": 8.5,
"CAP-02": 36
}
That is a group-by and an aggregate in nineteen characters.
The part that actually changes your architecture
Everything above is convenient. This next part is the reason to reach for it.
A JSONata expression is a string. That means the mapping between two systems can live wherever your other data lives, instead of inside a build artifact.
Consider three payment providers. Each sends a webhook, and none of them agree.
{
"id": "ch_1P2x",
"amount": 4999,
"currency": "usd",
"status": "succeeded",
"created": 1762159200
}
{
"entity": "payment",
"payment_id": "pay_NxQ2",
"amount_paid": "4999",
"curr": "INR",
"state": "captured",
"created_at": "2025-11-03T08:40:00Z"
}
You want one internal shape, whatever arrived. So write one expression per provider.
{
"paymentId": id,
"amountMinor": amount,
"currency": $uppercase(currency),
"status": status = "succeeded" ? "paid" : "failed",
"capturedAt": $fromMillis(created * 1000)
}
{
"paymentId": payment_id,
"amountMinor": $number(amount_paid),
"currency": $uppercase(curr),
"status": state = "captured" ? "paid" : "failed",
"capturedAt": $fromMillis($toMillis(created_at))
}
Both produce the same thing.
{
"paymentId": "ch_1P2x",
"amountMinor": 4999,
"currency": "USD",
"status": "paid",
"capturedAt": "2025-11-03T08:40:00.000Z"
}
Now store those two strings in a table keyed by provider. Your ingest code stops knowing anything about providers.
const mapping = await db.mappings.findOne({ provider });
const unified = jsonata(mapping.expression).evaluate(payload);
That is the whole handler. Adding a fourth provider is an insert, not a release. When a partner renames a field on a Tuesday, someone fixes it in a config screen and the fix is live in seconds. No branch, no review queue, no deploy window.
I want to be careful about how far to push that idea, because it is the kind of thing that sounds unambiguously good and is not. More on that below.
Functions worth learning first
The standard library is larger than you need. These are the ones I use constantly.
$map, $filter and $reduce do what you expect, and take lambdas.
$reduce(order.lines, function($acc, $line) { $acc + ($line.qty * $line.price) }, 0)
$merge combines objects, which is how you apply defaults.
$merge([{ "currency": "USD", "status": "pending" }, $])
The chain operator ~> applies functions left to right, which reads far better
than nesting them.
order.id ~> $lowercase() ~> $trim()
$exists and the ?: conditional handle the field that is sometimes absent.
$exists(order.discount) ? order.discount : 0
And $$ is the root document, which matters once you are deep in a traversal and
need something from the top.
order.lines.{ "sku": sku, "orderId": $$.order.id }
The gotcha that will get you
JSONata collapses a sequence of one into the item itself. This is the single thing to internalize before you ship anything, and it is worth being precise about when it happens, because the rule is narrower than most explanations suggest.
It does not happen to a stored array. If the document holds an array, you get the array back, however many items are in it.
{ "tags": ["urgent"] }
tags
["urgent"]
It happens when a path step produces a sequence of one. A filter is the usual culprit.
{
"lines": [
{ "sku": "TSH-01", "qty": 9 },
{ "sku": "MUG-04", "qty": 1 }
]
}
lines[qty > 5].sku
"TSH-01"
A string. Not ["TSH-01"]. Add a second line with qty above five and the same
expression returns an array instead.
That is what makes this bug so unpleasant. The type of your output depends on how many rows happened to match, so it passes every test you wrote with two matching records and fails on the customer who only had one. Mapping over a single-element array does the same thing.
lines.sku
Two lines gives you an array. One line gives you a string.
The fix is the array constructor, which forces a sequence to stay a sequence.
lines[qty > 5].sku[]
["TSH-01"]
Put [] on every field you have declared as an array in your output schema. Every
time, whether or not you think it can match once. It costs two characters and it
removes an entire category of bug that only shows up on real data.
The related behavior is sequence flattening.
{
"orders": [
{ "items": ["a", "b"] },
{ "items": ["c"] }
]
}
orders.items
["a", "b", "c"]
You get one flat array, not an array of arrays. This is usually what you want, and occasionally it is very much not, and it is worth knowing which one you are relying on.
Where it does not belong
I said the config-driven idea needs care. Here is what I have learned to watch.
Executing a stored expression is executing code. JSONata has no file access and no network, which rules out the worst outcomes, but an expression can still be written to burn CPU on a large document. If the expression comes from anywhere outside your team, run it with a timeout and a depth limit, and treat the config screen as a privileged surface.
There are no types and no schema. Rename a field upstream and the expression
does not throw. It quietly produces undefined for that key, your record is
written with a missing value, and nothing surfaces until something downstream
counts on it. Validate the output against a schema after transforming. The
expression is flexible, so the guard has to be somewhere else.
Debugging is thin. Errors point at a position in the expression, not at your data, and a long expression that returns nothing gives you very little to go on. Build them up piece by piece in the JSONata Exerciser rather than in your application.
Complex logic wants a real language. If an expression grows past thirty lines or needs branching several levels deep, that is the language telling you it is the wrong tool for that job. Transformation is what it is good at. Business rules with real complexity should be code you can test and step through.
Performance is fine until it is not. For documents in the kilobytes it is not worth measuring. For very large arrays, in a hot path, measure. Compile the expression once and reuse it rather than parsing on every call.
const compiled = jsonata(expression); // once, at startup or on config change
const result = await compiled.evaluate(payload); // per request
Where it fits
The pattern I would suggest: keep JSONata for the boundary. The messy edge where external shapes arrive and have to become your shape. That is where the volume of small, frequently changing, structurally similar logic actually lives, and it is where moving that logic out of your deploy cycle pays for itself.
Keep your core domain logic in code. A stored expression is worth it when the alternative is a deploy for a renamed field. It is not worth it when the alternative is a function with tests.
Learn the [] rule on day one. It is the difference between JSONata being
pleasant and JSONata being a source of intermittent bugs you cannot reproduce.