The dataLayer is the contract between your website and Google Tag Manager. Everything GTM knows about a page, a user or an interaction arrives through it. Get the contract wrong and you get unreliable analytics, tags that fire on the wrong pages, and a permanent low-grade firefight. Get it right and every future analytics or marketing request becomes a configuration change rather than a development ticket.
1. What it actually is
window.dataLayer is a plain JavaScript array. That is the whole thing. What makes it special is that when the GTM container loads, it replaces the array’s push() method with its own, so every subsequent push is intercepted and run through the GTM rules engine before the value lands in the array.
window.dataLayer = window.dataLayer || [];dataLayer.push({ event: 'page_view', page_name: 'Home'});
The first line creates the array if it does not already exist, and reuses it if it does. That || [] guard matters because the GTM snippet does the same thing, and whichever runs first wins.
The event key is the only one GTM treats specially: it fires a Custom Event trigger. Every other key becomes readable through a Data Layer Variable. So page_name above does not cause anything to happen, it simply becomes available to any tag that asks for it.
There is one way to break this permanently, and it is worth knowing before anything else. Never reassign the array.Writing dataLayer = [{ event: 'x' }] or window.dataLayer = [] after GTM has loaded throws away the custom push() and replaces it with the ordinary one. Every push after that point lands in the array and GTM never sees it. Nothing errors, the console looks correct, and no tags fire. Always use .push().
2. The merge model
The dataLayer is cumulative. Each push merges into a running state rather than replacing it, and GTM variables resolve against that merged state rather than against the most recent message.
// Page loadsdataLayer.push({ page: { type: 'article', category: 'Tech' } });// User logs in three seconds laterdataLayer.push({ event: 'login', user: { id: 'usr_123', tier: 'pro' } });
After the second push, a variable reading page.type still returns 'article' and a variable reading user.tier returns 'pro', even though those values arrived in separate pushes. That is the behaviour you want almost all of the time: page context set once at load stays available to every event for the rest of the page.
It is also the single source of the most common ecommerce bug, and section 10 is entirely about it.
3. The event key
Without an event key, GTM receives the push, merges the values into state, and fires nothing.
// Silent: values become available, no trigger firesdataLayer.push({ user: { tier: 'pro' } });// Active: fires any Custom Event trigger listening for 'login'dataLayer.push({ event: 'login', user: { tier: 'pro' } });
Both are legitimate. Use the silent form when you are supplying context that other events will read later, and the active form when something has happened that a tag should respond to.
Getting this wrong in the other direction is a common cause of duplicate events: pushing event: 'purchase' twice, once on load and once from a callback, fires the tag twice.
4. Order of operations
Anything that supplies page-level context has to be pushed before the GTM snippet. GTM processes whatever is already in the array the moment it loads, in order, so pre-existing pushes are not lost.
<head> <!-- 1. Initialise and push page context --> <script> window.dataLayer = window.dataLayer || []; dataLayer.push({ page: { type: 'article', category: 'Technology' } }); </script> <!-- 2. GTM container snippet --> <script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0], j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f); })(window,document,'script','dataLayer','GTM-XXXXXX');</script></head>
Push after the snippet and the values still arrive, they just arrive late. Any tag on a pageview trigger will already have fired with those variables empty. That is why page type, content category, user login state and anything else a pageview tag needs belongs above the container.
5. Design rules
Four decisions made once at the start save an enormous amount of remediation later.
Nest related properties. A flat structure works and gets unmanageable at scale, because every new property competes for a top-level name.
// Flat: works, collides eventuallydataLayer.push({ pageType: 'article', pageCategory: 'Technology', userTier: 'pro', userId: 'usr_123'});// Nested: recommendeddataLayer.push({ page: { type: 'article', category: 'Technology' }, user: { id: 'usr_123', tier: 'pro' }});
In GTM, nested keys are read with dot notation in the Data Layer Variable field: page.type, user.tier. There is no extra configuration.
Use snake_case everywhere. GA4’s own schema is snake_case, so matching it means your dataLayer keys map straight onto GA4 parameters with no renaming step in GTM. Mixing conventions, pageType here and page_category there, means somebody has to remember which is which every time they build a tag.
Set defaults at page load. Push a complete object including the properties this particular page will not use.
dataLayer.push({ page: { type: 'other', category: '(not set)', author: '(not set)' }, user: { login_status: 'logged_out', tier: 'anonymous', id: '' }});
Without defaults, a variable on a page that does not set it resolves to undefined, and undefined behaves differently from an empty string in GTM lookup tables, trigger conditions and GA4 reports. Explicit (not set) also tells you in the reports that the value was genuinely absent rather than that the tracking broke.
Decide the schema before anyone writes code. The dataLayer is a contract, which means it is worth writing down. A one-page specification listing every key, its type, its allowed values and which pages fire it is the difference between an implementation that survives a site rebuild and one that does not.
6. What never goes in the dataLayer
The dataLayer is a global JavaScript variable on a public page. Any script on that page can read it, any browser extension can read it, and anyone can type dataLayer into the console and see the lot. Treat it as published.
No personally identifiable information. No email addresses, phone numbers, names, postal addresses, or full postcodes. This is not only a privacy question: Google’s terms prohibit sending PII to Analytics, and properties found doing it can be disabled. When you need email-based audience matching, push a SHA-256 hash rather than the address itself, and hash it server-side or before it reaches the page.
No payment data. Card numbers, CVVs and anything else in scope for PCI DSS should never be within reach of a tag manager.
No internal commercial data. Unit cost, margin, supplier, inventory position. Anyone can read it, including competitors.
No credentials or tokens. API keys, session tokens, anything that grants access.
The user object in the next section pushes an internal ID and a hashed email, which is the correct shape. What it deliberately does not push is anything that identifies the person to somebody reading the page source.
7. Non-ecommerce patterns
Page metadata
dataLayer.push({ page: { name: 'Resources:Blog:How AI is Changing Analytics', type: 'blog_post', category: 'AI and Data', subcategory: 'Analytics', author: 'Sarah Chen', publish_date: '2024-11-15', word_count: 3200, tags: ['ai', 'analytics', 'machine-learning'], language: 'en-GB' }});
page.name is a colon-delimited path that gives you a readable hierarchy in reports and can override GA4’s page_title where the HTML title is unhelpful. page.type is what most trigger conditions end up keying on, so keep its values to a short controlled list rather than letting it drift. category and subcategory feed GA4 content groupings, author enables author-level engagement reporting, and publish_date lets you look at engagement against content age.
page.tags is an array, and this is worth flagging. A Data Layer Variable pointed at page.tags returns the array itself, which GA4 cannot accept as a parameter value. You need a Custom JavaScript Variable to join it into a string, or you take the first element, or you push a pre-joined string alongside the array. Decide which before a developer builds it.
User and authentication state
dataLayer.push({ event: 'user_detected', user: { login_status: 'logged_in', id: 'usr_8a3f92k', hashed_email: 'b94d27...c2', account_type: 'organisation', subscription_tier: 'pro', account_age_days: 312, crm_segment: 'power_user' }});
Fire this before the container when the user is known at render time, and from the login success callback when authentication happens over AJAX. The event key is here because a tag usually needs to react, typically to set the GA4 user_id.
Everything in that object is either an opaque internal identifier or a categorical attribute, which is what section 6 requires.
Form interactions
Track the whole funnel rather than just the submit, because the drop-off between start and submit is where the useful information lives.
// First interaction with any fielddataLayer.push({ event: 'form_start', form_id: 'contact-us-main', form_name: 'Contact Us', form_type: 'contact'});// Validation failuredataLayer.push({ event: 'form_error', form_id: 'contact-us-main', field_name: 'email', error_type: 'invalid_format'});// Confirmed submission, fired on the success response, not the clickdataLayer.push({ event: 'form_submit', form_id: 'contact-us-main', form_name: 'Contact Us', form_type: 'contact', lead_type: 'inbound_enquiry'});
Fire form_submit when the server confirms, not when the button is clicked. Clicking a submit button that then fails validation is not a submission, and counting it as one inflates your conversion rate in a way nobody notices for months.
CTA clicks and video
dataLayer.push({ event: 'cta_click', cta_text: 'Start Free Trial', cta_location: 'homepage_hero', cta_type: 'primary_button', destination: '/signup/'});
cta_location is the parameter that earns its keep. Knowing that a trial button was clicked is nearly useless; knowing whether it was the hero, the pricing table or the footer is what changes a design decision.
dataLayer.push({ event: 'video_start', video_title: 'Product Demo', video_provider: 'youtube', video_id: 'abc123', video_duration: 243});dataLayer.push({ event: 'video_progress', video_title: 'Product Demo', video_percent: 50, video_current_time: 121});dataLayer.push({ event: 'video_complete', video_title: 'Product Demo', video_id: 'abc123'});
Milestones at 25, 50, 75 and 90 percent are conventional. For YouTube embeds specifically, GTM has a built-in YouTube trigger that does all of this without any dataLayer work, so only hand-roll it for a custom player.
8. GA4 ecommerce: the items array
GA4’s ecommerce schema is strict. Every ecommerce event carries an ecommerce object containing an items array, and every object in that array follows the same fixed shape. Deviate from it and the reports do not error, they simply come out empty or wrong.
{ item_id: 'SKU-00441', // required item_name: 'Alpine Parka', // required item_brand: 'NorthRidge', item_category: 'Outerwear', item_category2: 'Jackets', // through to item_category5 item_variant: 'Navy / L', item_list_id: 'cat_outerwear', // list events only item_list_name: 'Outerwear', // list events only index: 3, // 1-based position in the list price: 299.99, // unit price, not line total quantity: 1, coupon: 'WINTER20', discount: 60.00 // absolute amount per unit}
Only item_id and item_name are required, and in practice GA4 accepts an item with either one. Everything else is optional and each omission costs you a report.
Three of these cause most of the trouble.
item_id must be identical across every event. If the product listing pushes SKU-00441 and the purchase pushes 441, GA4 treats them as two different products and the funnel from view to purchase silently breaks. Pick one format, usually whatever the backend already uses, and enforce it everywhere.
price is the unit price, not the line total. Three items at 29.99 is price: 29.99, quantity: 3, never price: 89.97.
discount is an absolute amount per unit, not a percentage. A 20 percent discount on a 300 pound item is discount: 60.00.
The category hierarchy runs item_category through item_category5, filled top-down. item_list_id and item_list_name belong on both the event and the individual items, and GA4 uses the item-level values when both are present.
9. The event sequence
The full journey, in order. Each one clears the previous ecommerce object first, for the reason section 10 explains.
Product list, category page or search results:
dataLayer.push({ ecommerce: null });dataLayer.push({ event: 'view_item_list', ecommerce: { item_list_id: 'cat_outerwear', item_list_name: 'Outerwear', items: [ { item_id: 'SKU-00441', item_name: 'Alpine Parka', item_brand: 'NorthRidge', item_category: 'Outerwear', item_category2: 'Jackets', item_variant: 'Navy / L', item_list_id: 'cat_outerwear', item_list_name: 'Outerwear', index: 1, price: 299.99 }, { item_id: 'SKU-00389', item_name: 'Fur Trim Ski Jacket', item_brand: 'NorthRidge', item_category: 'Outerwear', item_category2: 'Jackets', item_list_id: 'cat_outerwear', item_list_name: 'Outerwear', index: 2, price: 129.99 } ] }});
Send what the user can actually see, not the whole catalogue. GA4 caps events at 200 items, and long before that the request grows large enough to be slow or truncated. On a page of 200 products, push the first page of results or the items currently in the viewport.
Click from that list:
dataLayer.push({ ecommerce: null });dataLayer.push({ event: 'select_item', ecommerce: { item_list_id: 'cat_outerwear', item_list_name: 'Outerwear', items: [ { item_id: 'SKU-00441', item_name: 'Alpine Parka', item_brand: 'NorthRidge', item_category: 'Outerwear', item_list_id: 'cat_outerwear', item_list_name: 'Outerwear', index: 1, price: 299.99 } ] }});
Product detail page:
dataLayer.push({ ecommerce: null });dataLayer.push({ event: 'view_item', ecommerce: { currency: 'GBP', value: 299.99, items: [ { item_id: 'SKU-00441', item_name: 'Alpine Parka', item_brand: 'NorthRidge', item_category: 'Outerwear', item_category2: 'Jackets', item_variant: 'Navy / L', price: 299.99, quantity: 1 } ] }});
From view_item onwards, currency and value are both required. value is the total for the event, so it is unit price times quantity summed across the items array.
Cart changes:
dataLayer.push({ ecommerce: null });dataLayer.push({ event: 'add_to_cart', ecommerce: { currency: 'GBP', value: 599.98, // 299.99 x 2, not 299.99 items: [ { item_id: 'SKU-00441', item_name: 'Alpine Parka', item_brand: 'NorthRidge', item_category: 'Outerwear', item_variant: 'Navy / L', price: 299.99, quantity: 2 } ] }});
remove_from_cart is identical in shape with the event name changed. Both fire on the interaction, never on page load.
Cart page:
dataLayer.push({ ecommerce: null });dataLayer.push({ event: 'view_cart', ecommerce: { currency: 'GBP', value: 429.98, // whole cart items: [ { item_id: 'SKU-00441', item_name: 'Alpine Parka', price: 299.99, quantity: 1 }, { item_id: 'SKU-00389', item_name: 'Fur Trim Ski Jacket', price: 129.99, quantity: 1 } ] }});
Checkout steps:
dataLayer.push({ ecommerce: null });dataLayer.push({ event: 'begin_checkout', ecommerce: { currency: 'GBP', value: 429.98, coupon: 'WINTER20', items: [ { item_id: 'SKU-00441', item_name: 'Alpine Parka', price: 299.99, quantity: 1, coupon: 'WINTER20', discount: 60.00 }, { item_id: 'SKU-00389', item_name: 'Fur Trim Ski Jacket', price: 129.99, quantity: 1 } ] }});
add_shipping_info and add_payment_info follow, each adding one parameter to the same structure: shipping_tier: 'standard_3-5_days'on the first and payment_type: 'credit_card' on the second. Fire them when the user commits to a choice and moves on, not when they merely look at the options.
Purchase, which is the one that has to be right:
dataLayer.push({ ecommerce: null });dataLayer.push({ event: 'purchase', ecommerce: { transaction_id: 'ORD-2024-98765', // required, must be unique currency: 'GBP', value: 409.98, // after discount, before tax and shipping tax: 68.33, shipping: 9.99, coupon: 'WINTER20', affiliation: 'Online Store', items: [ { item_id: 'SKU-00441', item_name: 'Alpine Parka', item_brand: 'NorthRidge', item_category: 'Outerwear', item_variant: 'Navy / L', price: 239.99, quantity: 1, coupon: 'WINTER20', discount: 60.00 }, { item_id: 'SKU-00389', item_name: 'Fur Trim Ski Jacket', item_brand: 'NorthRidge', item_category: 'Outerwear', price: 129.99, quantity: 1 } ] }});
Two things decide whether your revenue reporting is correct.
value is revenue after discounts and before tax and shipping. Tax and shipping have their own fields and adding them into value inflates every revenue metric you have. This is a convention rather than a validation rule, so nothing tells you when you get it wrong.
transaction_id must be unique per order and stable across retries. GA4 deduplicates purchases on this key, which is what saves you when a customer refreshes the confirmation page. Generate it from the order, never from a timestamp or a random number, or the refresh becomes a second sale.
Refunds take the transaction ID and, for a partial refund, only the returned items:
dataLayer.push({ ecommerce: null });dataLayer.push({ event: 'refund', ecommerce: { transaction_id: 'ORD-2024-98765', currency: 'GBP', value: 239.99, items: [ { item_id: 'SKU-00441', item_name: 'Alpine Parka', price: 239.99, quantity: 1 } ] }});
Omit the items array entirely for a full refund and set value to the whole order.
10. The ecommerce null clear
dataLayer.push({ ecommerce: null });dataLayer.push({ event: 'view_item', ecommerce: { /* new data */ } });
This exists because of the merge model in section 2. The dataLayer never forgets, so an items array pushed for one event remains in the merged state and is still readable by the next one. A user who views a product, then goes back and views a different one, generates a second view_item whose tag reads whichever items GTM resolves from the merged state, which may include the first product.
Setting ecommerce to null wipes the whole branch before the new data arrives. Two details make it work: it has to be its own push, because a single push merges once and cannot both clear and set the same key, and it has to be null rather than {}, because an empty object merges without removing the nested keys underneath.
Google’s own documentation recommends this and it is not optional. On a single-page application it is critical, because there is no page reload to reset anything and every ecommerce event of the session accumulates in one merged object.
11. Wiring it up in GTM
Every key you push needs a Data Layer Variable before a tag can read it. The naming convention barely matters as long as it is consistent, and a DLV - prefix keeps them together in the variable list.
DLV - Ecommerce Items -> ecommerce.itemsDLV - Transaction ID -> ecommerce.transaction_idDLV - Value -> ecommerce.valueDLV - Currency -> ecommerce.currencyDLV - Tax -> ecommerce.taxDLV - Shipping -> ecommerce.shippingDLV - Coupon -> ecommerce.couponDLV - Payment Type -> ecommerce.payment_typeDLV - Shipping Tier -> ecommerce.shipping_tierDLV - Page Type -> page.typeDLV - User Login Status -> user.login_status
Then in the GA4 Event tag:
Event Name: purchaseTrigger: Custom Event, event name = purchaseEvent Parameters transaction_id -> {{DLV - Transaction ID}} value -> {{DLV - Value}} currency -> {{DLV - Currency}} tax -> {{DLV - Tax}} shipping -> {{DLV - Shipping}} coupon -> {{DLV - Coupon}} items -> {{DLV - Ecommerce Items}}
Note the trigger. It must be a Custom Event trigger matching the event name, never All Pages. A pageview trigger fires when the container loads, which on many sites is before the ecommerce push, so the tag sends a purchase with no items and no value.
One Data Layer Variable setting is worth knowing about. Each variable has a Data Layer Version, and Version 2 is the default and the one you want, because it is the version that reads the merged state. Version 1 exists for legacy containers and reads only the most recent message.
12. Consent
Anything serving the EU or UK needs Consent Mode, and it interacts with the dataLayer directly. The default consent state must be set before the container loads, in the same block where you initialise the array.
<script> window.dataLayer = window.dataLayer || []; function gtag(){ dataLayer.push(arguments); } gtag('consent', 'default', { ad_storage: 'denied', ad_user_data: 'denied', ad_personalization: 'denied', analytics_storage: 'denied', functionality_storage: 'granted', security_storage: 'granted', wait_for_update: 500 });</script>
That gtag function is not a separate library, it is a two-line shim that pushes its arguments onto the dataLayer, which is how the consent commands reach GTM.
Deny by default and update when the user chooses, rather than the reverse. wait_for_update gives your consent banner a window to respond before tags decide how to behave.
Your consent platform then calls gtag('consent', 'update', {...}) with the granted values. Tags configured for consent will hold until that arrives, and Google’s modelling fills some of the gap for denied traffic.
The dataLayer implication is that you cannot assume a tag fired just because the push happened. When debugging a missing event, check the consent state before you check the trigger.
13. Single-page applications
On an SPA there is no page reload between views, so nothing resets. Three things become your responsibility on every route change.
Push the new page context, because the old values are still sitting in the merged state and will be read by any tag that fires next. Clear ecommerce to null, for the reason in section 10, which compounds across a whole session rather than a single page. And push a virtual pageview yourself, because GTM’s Page View trigger fires once at container load and never again.
function onRouteChange(route) { dataLayer.push({ ecommerce: null }); dataLayer.push({ event: 'virtual_page_view', page: { path: route.path, title: route.title, type: route.type, category: route.category || '(not set)' } });}
Fire this after the new view has rendered, not on navigation start, so the title and path you push are the ones the user actually sees.
GTM’s built-in History Change trigger catches pushState navigation and is a reasonable fallback when you cannot get a developer to add the push. It is only a fallback: it tells you the URL changed and nothing about what is now on the page.
14. Debugging
Three tools, in the order you should reach for them.
The console. Type dataLayer and you get the raw array of every message pushed, in order. This tells you whether the push happened at all, which is the first thing to establish and is frequently the answer.
GTM Preview mode. The left panel lists every event in sequence, and selecting one shows two tabs that matter. The Data Layer tab shows the merged state at that moment, which is what your variables will actually resolve to. The Variables tab shows every variable’s resolved value for that event, which is where you discover that ecommerce.items is undefined because the push arrived after the trigger.
The GA4 DebugView. In the GA4 interface under Admin, this shows what actually reached Google, as opposed to what GTM thinks it sent. When Preview mode looks correct and the reports are empty, the answer is between these two: usually consent, an ad blocker, or a parameter GA4 rejected for being the wrong type.
The habit worth building is to check them in that order. Most of the time the problem is that the push never happened or happened too late, and both are visible in the console in five seconds.
15. Common pitfalls
Reassigning the array. dataLayer = [...] after the container loads destroys GTM’s push() override and silently disables everything downstream. Only ever .push().
Forgetting ecommerce: null. Items from a previous event persist in the merged state and contaminate the next one.
Clearing with {} instead of null. An empty object merges without removing the nested keys.
Pushing ecommerce data with no event key. The values merge into state and no trigger fires, so the tag never runs.
Pushing after the GTM snippet when the value is needed on load. Any pageview-triggered tag has already fired with the variable empty.
Using an All Pages trigger for an ecommerce tag. It fires at container load, before the push. Use a Custom Event trigger matching the event name.
Treating value as the unit price. Three items at 29.99 is a value of 89.97.
Including tax and shipping in value on purchase. They have their own fields and adding them inflates revenue.
Inconsistent item_id between events. SKU-00441 in one event and 441 in another means GA4 cannot stitch the product journey.
A transaction_id derived from a timestamp. Deduplication depends on it being stable, so a refresh of the confirmation page becomes a second order.
Pushing the whole catalogue into view_item_list. 200 products makes a request that is slow at best and truncated at worst. Send what is visible.
Forgetting the null clear on an SPA. With no page reload, every ecommerce event of the session accumulates in one object.
Scraping values out of the DOM with auto-event triggers. Reading a price from a <span class="price"> is fragile, breaks on any template change, and gets the wrong number as soon as the site is localised. A real push is always better.
Pushing PII. Raw email addresses and phone numbers in the dataLayer are readable by anything on the page and are against Google’s terms.
Three things carry the whole subject. The dataLayer is a merged, cumulative state rather than a stream of independent messages, which is why context set once stays available and why stale ecommerce data contaminates the next event unless you clear it. The event key is the only thing that makes GTM act, so every push is either supplying context or triggering a tag, and knowing which you meant prevents both silent failures and duplicates. And it is a public object on a public page, so the schema decision about what belongs in it is a privacy decision as much as an analytics one.
Write the schema down before anyone writes the code. Everything after that is typing.
See you soon.
[…] dataLayer: https://datalad.co.uk/2024/08/16/comprehensive-data-layer-guide/ […]
[…] different layer. The Tag Explorer extension gives you a fast visual check of which categories fire. GTM preview confirms the consent logic behind those tags is correctly wired. The network tab proves […]
[…] few failure modes account for most GTM debugging sessions. A trigger set to All Elements without a filter fires on every click and […]
[…] dataLayer: https://datalad.co.uk/comprehensive-data-layer-guide/ […]
[…] create Data Layer Variables to read the pushed […]
[…] each step of the journey, GTM listens with one Custom Event trigger per event, a single ecommerce Data Layer Variable feeds the data through, and GA4 event tags with ecommerce data enabled forward the whole […]
[…] or thank-you page where the user’s details are known, the site pushes the user data to the dataLayer. The cleanest pattern bundles it under a single object alongside the conversion […]