Tutorial: Turning a Nested dataLayer Object Into Durable GTM Variables

Contents
A shop pushes its product data as one nested object, because that is how the shop already holds it. The tag manager wants flat values, one per variable, and the bridge between the two is a notation with dots in it.
That notation reaches everything. What it does not do is tell which of the resulting paths will still be correct next month – and about half of them will not, for a reason that has nothing to do with the tag manager.

What a Nested Push Looks Like on Arrival
A typical e-commerce push carries three levels: the event, an object below it, and an array of items below that.
dataLayer.push({
event: "add_to_cart",
ecommerce: {
currency: "EUR",
value: 129.90,
items: [
{ item_id: "SKU-42", item_name: "Stuhl", price: 64.95, quantity: 2,
item_category: "Moebel", index: 0 },
{ item_id: "SKU-77", item_name: "Tisch", price: 219.00, quantity: 1,
item_category: "Moebel", index: 1 }
]
},
user: { logged_in: true, segment: "b2b" }
});
Written out as paths, this object contains one event name, three values under ecommerce, twelve inside the item array and two under user. Each of them is addressable, and the address is the chain of keys joined by dots.
Version 2 and the Dot That Is a Separator
A data layer variable in the tag manager has a version setting, and it decides how the name is read.
Version 2: ecommerce.currency → "EUR"
ecommerce.items.0.item_id → "SKU-42"
user.segment → "b2b"
Version 1: ecommerce.currency → undefined
(the whole string is treated as one key)
Version 2 is the default for new variables and the right choice almost always. Version 1 exists for one specific case: a key that genuinely contains a dot in its name. A push with {"page.type": "pdp"} is only reachable with version 1, because version 2 would look for a type inside a page that does not exist.
Two details that cost time when they are unknown. The path is case-sensitive throughout, so Ecommerce.Items finds nothing and reports nothing. And a variable with no default value returns undefined for a missing path, which in a tag becomes an empty parameter rather than an error – so a mistyped path looks exactly like a value that was not sent.
Arrays, and Why the Index Is the Fragile Part
The step into an array is a number, and that number is a position rather than an identity.
| Path | Stable? | Why |
|---|---|---|
| ecommerce.currency | yes | A named key at a fixed depth |
| ecommerce.value | yes | Same |
| user.segment | yes | Same |
| ecommerce.items.0.item_id | no | Position 0 is whichever item happens to be first |
| ecommerce.items.1.price | no | Missing entirely on a cart with one item |
The second row of that lower half is the one that produces silent damage. On a single-item cart, items.1 does not exist, the variable is empty, and the tag sends a parameter without a value. Nothing fails, and the report shows a slightly smaller number than the shop does.
An index path is defensible in exactly one situation: a page that always has exactly one item, such as a product detail page. Everywhere else – cart, checkout, purchase – the number of items is variable, and so is their order.
The Merge That Nobody Asked For
The data layer is not a list of messages. The tag manager keeps a merged model of everything pushed so far, and a new push is merged into it rather than replacing it.
The consequence is specific and unpleasant. A page that first pushes a view_item with one item and then an add_to_cart with a different one leaves the first item’s values in place wherever the second push does not overwrite them. An item array of length three followed by one of length one produces a merged array of length three, with two entries from the previous event.
dataLayer.push({ ecommerce: null }); // clears the branch
dataLayer.push({
event: "add_to_cart",
ecommerce: { currency: "EUR", value: 64.95, items: [ /* … */ ] }
});
The clearing push belongs before every e-commerce push without exception, and it is the single most common omission in a shop integration. Its effect is visible in preview: the message view shows what was pushed, the model view shows what a variable will actually read, and the difference between the two is exactly this problem.
One Variable Instead of Twenty
Where a whole array is needed, the answer is not twenty index paths. It is one custom JavaScript variable that reads the array and returns what the tag needs.
function () {
var artikel = {{DLV - ecommerce.items}};
if (!Array.isArray(artikel) || !artikel.length) { return undefined; }
return artikel.map(function (a) {
return {
item_id: String(a.item_id || a.id || ""),
item_name: String(a.item_name || a.name || ""),
price: Number(a.price) || 0,
quantity: Number(a.quantity) || 1,
item_category: String(a.item_category || "")
};
}).filter(function (a) { return a.item_id !== ""; });
}
Three things this buys beyond robustness against the order. It normalises the field names, so a shop that sends id on one page and item_id on another produces one shape downstream. It coerces the types, which is where a price arriving as the string "64.95" stops being a problem. And it drops items without an identifier, which GA4 would discard anyway – but silently.
The variable it reads is a normal data layer variable pointing at ecommerce.items, without an index. That path is stable, because it names a key rather than a position.
Checking It in Preview
Two views in the preview settle everything discussed above, and they are easy to confuse with each other.
The message view shows the push exactly as the page sent it. The model view shows the merged state a variable will read at that moment. A value that is in the second but not the first is a leftover from an earlier push – which is the clearing problem – and a value in the first but not the second usually means a later push overwrote it.
The third view, the variables tab of the selected event, shows what each configured variable actually returned. An undefined there is the answer to almost every question about a missing parameter, and it distinguishes the two possible causes: the path is wrong, or the value was never pushed. Comparing the variable against the model view separates them in seconds.
One last habit that prevents the most tedious class of error. A path taken from a page’s source is not a path taken from the data layer – a shop plugin can rename keys between the template and the push, and a translated shop sometimes localises them. The path belongs to be copied from the model view of a real event, not typed from the documentation.