What This Lesson Covers
The dataLayer is the contract between your website and GTM. Everything GTM knows about a page, a user, or an interaction comes from it. A poorly designed data layer produces unreliable analytics, broken tags, and constant firefighting. A well-designed one makes every future analytics and marketing requirement trivially easy to implement.
This lesson covers the data layer from first principles through to a complete GA4 ecommerce implementation, with full non-ecommerce patterns alongside.
Part 1: How the dataLayer Actually Works
The Mechanics
window.dataLayer is a plain JavaScript array. GTM installs a custom push() method on it at load time that intercepts every push and processes the object through the GTM rules engine.

// The dataLayer is initialised before the GTM snippetwindow.dataLayer = window.dataLayer || [];// Every push is an object — GTM processes it immediatelydataLayer.push({ event: 'page_view', pageName: 'Home'});
| Line | What it does |
dataLayer.push({...}) | After GTM loads, this is intercepted — GTM reads the object, evaluates triggers, fires matching tags |
event: 'page_view' | The event key is special — it fires a Custom Event trigger in GTM |
pageName: 'Home' | Any other key becomes readable via a Data Layer Variable in GTM |
State Persistence: the Merge Model
The dataLayer is cumulative and stateful. Each push merges into the running state rather than replacing it. GTM variables resolve against the merged state, not just the most recent push.
// Push 1 — page loadsdataLayer.push({ page: { type: 'article', category: 'Tech' } });// Push 2 — user logs in 3 seconds laterdataLayer.push({ event: 'login', user: { id: 'usr_123', tier: 'pro' } });// At this point GTM variables can read BOTH page.type AND user.tier// even though they came from different pushes
The event Key: GTM’s Trigger Signal
Without the event key, GTM receives the push, merges the values into state, but fires no triggers. The event key is the only way to tell GTM “react to this push right now.”


// Silent push — values available but no trigger firesdataLayer.push({ user: { tier: 'pro' } });// Active push — fires Custom Event trigger for 'login'dataLayer.push({ event: 'login', user: { tier: 'pro' } });
Part 2: Data Layer Architecture Principles
1. Initialise Before GTM
All pushes that supply page-level context must come before the GTM snippet. GTM processes the queue of pre-existing pushes immediately on load.
<head> <!-- 1. dataLayer initialisation and page-level push --> <script> window.dataLayer = window.dataLayer || []; dataLayer.push({ page: { type: 'article', category: 'Technology' } }); </script> <!-- 2. GTM snippet — reads the push above immediately --> <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>
Use Nested Objects for Clarity
Flat structures work but become hard to maintain. Nested objects group related properties and prevent naming collisions.
// Flat — works but gets messy at scaledataLayer.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.
Use snake_case for All Keys
GA4’s native schema uses snake_case. Using it throughout the data layer eliminates the need for transformation in GTM.
// Inconsistent casing — requires GTM transformationsdataLayer.push({ pageType: 'article', page_category: 'Tech', PageAuthor: 'Jo' });// Consistent snake_case — maps directly to GA4 parametersdataLayer.push({ page_type: 'article', page_category: 'Tech', page_author: 'Jo' });
Always Set Default Values
Push a complete object with default values at page load, even for properties that will be overridden later. This prevents GTM variables from being undefined on pages that do not update them.
// At page load — establish defaults for every variabledataLayer.push({ page: { type: 'other', // overridden on specific page types category: '(not set)', author: '(not set)' }, user: { login_status: 'logged_out', tier: 'anonymous', id: '' }});
Part 3: Non-Ecommerce Data Layer Patterns
3.1 Page Metadata
// Fires at page load, before GTM snippetdataLayer.push({ page: { name: 'Resources:Blog:How AI is Changing Analytics', type: 'blog_post', category: 'AI & Data', subcategory: 'Analytics', author: 'Sarah Chen', publish_date: '2024-11-15', word_count: 3200, tags: ['ai', 'analytics', 'machine-learning'], language: 'en-GB' }});
| Line | What it does |
page.name | Full colon-delimited page name — maps to GA4 page_title override if needed |
page.type | Classifies the page for trigger conditions in GTM |
page.category / subcategory | Content taxonomy — powers GA4 content groupings |
page.author | Enables author-level engagement reporting |
page.publish_date | Lets you analyse content freshness vs. engagement |
page.word_count | Correlate with scroll depth and time on page |
page.tags | Array — requires processing in GTM (join to string or use first value) |
3.2 User / Authentication State – example
// Fires immediately if user is known at page load (server-rendered)// Fires inside onLoginSuccess() callback if authenticated via AJAXdataLayer.push({ event: 'user_detected', user: { login_status: 'logged_in', id: 'usr_8a3f92k', // hashed internal ID hashed_email: 'b94d27...c2', // SHA-256 for audience matching account_type: 'organisation', subscription_tier: 'pro', account_age_days: 312, crm_segment: 'power_user' }});
3.3 Form Interactions example
Track the full form funnel: start → field interaction → error → submit → success.
// Form started (first interaction with any field)dataLayer.push({ event: 'form_start', form_id: 'contact-us-main', form_name: 'Contact Us', form_type: 'contact'});// Field-level errordataLayer.push({ event: 'form_error', form_id: 'contact-us-main', field_name: 'email', error_type: 'invalid_format'});// Successful submissiondataLayer.push({ event: 'form_submit', form_id: 'contact-us-main', form_name: 'Contact Us', form_type: 'contact', lead_type: 'inbound_enquiry'});
3.4 CTA and Internal Link Clicks – example
// Fires on click of a tracked element (via GTM Click trigger or custom listener)dataLayer.push({ event: 'cta_click', cta_text: 'Start Free Trial', cta_location: 'homepage_hero', cta_type: 'primary_button', destination: '/signup/'});
3.5 Video Engagement – example
// Video playdataLayer.push({ event: 'video_start', video_title: 'Product Demo — Reporting Dashboard', video_provider: 'youtube', video_id: 'dQw4w9WgXcQ', video_duration: 243 // seconds});// Progress milestonesdataLayer.push({ event: 'video_progress', video_title: 'Product Demo — Reporting Dashboard', video_percent: 50, // 25, 50, 75, 90 video_current_time: 121});// CompletiondataLayer.push({ event: 'video_complete', video_title: 'Product Demo — Reporting Dashboard', video_id: 'dQw4w9WgXcQ'});
Part 4: GA4 Ecommerce Data Layer
GA4’s ecommerce schema uses a strict structure. Every ecommerce event requires an items array. Each item in the array follows a fixed set of parameters. Deviating from the schema silently breaks GA4’s ecommerce reports.
The Items Array: Universal Structure
Every product in every ecommerce event uses the same object shape:
// Template — use this structure for every item in every ecommerce event{ item_id: 'SKU-00441', // required item_name: 'Alpine Parka', // required item_brand: 'NorthRidge', item_category: 'Outerwear', item_category2: 'Jackets', // up to item_category5 item_variant: 'Navy / L', item_list_id: 'cat_outerwear', // only on list events item_list_name: 'Outerwear', // only on list events index: 3, // position in list price: 299.99, // unit price, not line total quantity: 1, coupon: 'WINTER20', discount: 60.00 // absolute discount amount}
| Parameter | Required | Notes |
item_id | Yes | SKU or product ID — must be consistent across all events |
item_name | Yes | Human-readable product name |
item_brand | No | Brand name for brand-level reporting |
item_category | No | Primary category — up to 5 levels via item_category2–item_category5 |
item_variant | No | Size, colour, configuration |
item_list_id | No | Machine-readable list identifier |
item_list_name | No | Human-readable list name |
index | No | Product position in a list (1-based) |
price | No | Unit price after discount |
quantity | No | Defaults to 1 if omitted |
coupon | No | Item-level coupon code |
discount | No | Absolute discount amount per unit |
4.1 view_item_list: Product List / Category Page
Fires when a user sees a list of products (category page, search results, recommendations).
dataLayer.push({ ecommerce: null }); // clear previous ecommerce object firstdataLayer.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 } ] }});
4.2 select_item: Product Click from a 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 } ] }});
4.3 view_item: Product Detail Page
dataLayer.push({ ecommerce: null });dataLayer.push({ event: 'view_item', ecommerce: { currency: 'GBP', value: 299.99, // unit price of the item being viewed 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 } ] }});
4.4 add_to_cart and remove_from_cart
// Add to cart — fires on button click, not on page loaddataLayer.push({ ecommerce: null });dataLayer.push({ event: 'add_to_cart', ecommerce: { currency: 'GBP', value: 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: 1 } ] }});// Remove from cart — fires when item is removed from cart pagedataLayer.push({ ecommerce: null });dataLayer.push({ event: 'remove_from_cart', ecommerce: { currency: 'GBP', value: 299.99, items: [ { item_id: 'SKU-00441', item_name: 'Alpine Parka', price: 299.99, quantity: 1 } ] }});
4.5 view_cart: Cart Page
dataLayer.push({ ecommerce: null });dataLayer.push({ event: 'view_cart', ecommerce: { currency: 'GBP', value: 429.98, // total cart value 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 } ] }});
4.6 begin_checkout
dataLayer.push({ ecommerce: null });dataLayer.push({ event: 'begin_checkout', ecommerce: { currency: 'GBP', value: 429.98, coupon: 'WINTER20', // cart-level coupon if applied 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 } ] }});
4.7 add_shipping_info and add_payment_info
// Fires when user selects a shipping method and proceedsdataLayer.push({ ecommerce: null });dataLayer.push({ event: 'add_shipping_info', ecommerce: { currency: 'GBP', value: 429.98, shipping_tier: 'standard_3-5_days', items: [ /* same items array */ ] }});// Fires when user selects a payment method and proceedsdataLayer.push({ ecommerce: null });dataLayer.push({ event: 'add_payment_info', ecommerce: { currency: 'GBP', value: 429.98, payment_type: 'credit_card', items: [ /* same items array */ ] }});
4.8 purchase: Order Confirmation
The most important ecommerce event. Every parameter matters.
dataLayer.push({ ecommerce: null });dataLayer.push({ event: 'purchase', ecommerce: { transaction_id: 'ORD-2024-98765', // unique, required currency: 'GBP', value: 409.98, // revenue AFTER discount, BEFORE tax/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, // price after item-level discount 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 } ] }});
4.9 refund
// Full refunddataLayer.push({ ecommerce: null });dataLayer.push({ event: 'refund', ecommerce: { transaction_id: 'ORD-2024-98765', currency: 'GBP', value: 409.98 // full order value }});// Partial refund — specify only refunded itemsdataLayer.push({ ecommerce: null });dataLayer.push({ event: 'refund', ecommerce: { transaction_id: 'ORD-2024-98765', currency: 'GBP', value: 239.99, // partial amount refunded items: [ { item_id: 'SKU-00441', item_name: 'Alpine Parka', price: 239.99, quantity: 1 } ] }});
Part 5: GTM Variable Configuration
For every ecommerce.* key you push, create a corresponding Data Layer Variable in GTM:
| GTM Variable Name | Data Layer Key | Used In |
DLV - Ecommerce Items | ecommerce.items | All ecommerce event tags |
DLV - Transaction ID | ecommerce.transaction_id | Purchase tag |
DLV - Value | ecommerce.value | All ecommerce event tags |
DLV - Currency | ecommerce.currency | All ecommerce event tags |
DLV - Shipping | ecommerce.shipping | Purchase tag |
DLV - Tax | ecommerce.tax | Purchase tag |
DLV - Coupon | ecommerce.coupon | Checkout and purchase tags |
DLV - Payment Type | ecommerce.payment_type | add_payment_info tag |
DLV - Shipping Tier | ecommerce.shipping_tier | add_shipping_info tag |
In each GA4 Event tag in GTM, map these variables to GA4 parameters:
GA4 Event Tag: purchase
Event Name: purchase
Event 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}}
Part 6: The ecommerce: null Pattern Explained
// Always clear before a new ecommerce pushdataLayer.push({ ecommerce: null });dataLayer.push({ event: 'view_item', ecommerce: { /* new data */ }});
Without this clear, the dataLayer’s merge model means a purchase event’s items array is still readable after the purchase event has fired and can contaminate a subsequent view_item event on the same page if the user navigates back without a full reload.
Common Pitfalls
1. Forgetting ecommerce: null before ecommerce pushes Items from a previous product view persist in the merged dataLayer state. A purchase event tag reading ecommerce.items gets the items from the last view_item instead of the actual cart.
2. Pushing ecommerce data after the GTM snippet with no event key GTM reads the push but no trigger fires the GA4 event tag never executes.
3. Using value as the unit price instead of the total For add_to_cart with quantity 3 at £29.99 each, value must be 89.97, not 29.99. GA4 uses value for revenue metrics.
4. Inconsistent item_id across events If view_item uses 'SKU-00441' but purchase uses '441', GA4 cannot stitch the product journey. Use the same ID format everywhere.
5. Not clearing ecommerce: null on SPAs In single-page apps, the page does not reload between views. Without the null clear, every subsequent ecommerce event carries forward items from all previous ones.
6. Pushing the entire product catalogue into view_item_list If your category page loads 200 products, sending 200 items in the items array creates an enormous image request that may be truncated. Send only what is visible in the viewport, or the first page of results.
7. Using value inclusive of tax and shipping on purchase GA4 convention: value = revenue after discounts, before tax and shipping. Tax and shipping go in their own fields. Including them in value inflates revenue metrics.
8. Triggering view_item on page load via a GTM All Pages trigger If the All Pages trigger fires before the ecommerce push, the GA4 tag sends an event with an empty items array. Use a Custom Event trigger matching view_item instead.
9. Relying on GTM’s Auto-Event triggers to scrape ecommerce data from the DOM Reading price from a <span class="price"> element is fragile, locale-dependent, and breaks when the template changes. A proper dataLayer push is always preferable.
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 […]