Skip to content
ceaksan

How dataLayer Works: The Shared Layer for GTM, gtag.js, and Consent Mode

dataLayer is not exclusive to Google Tag Manager — gtag.js and Consent Mode V2 use the same object. We explain why initialization order causes silent data loss and the difference between push() and array declaration.

Apr 2, 2019 3 min read Updated: Apr 8, 2026
TL;DR

window.dataLayer is a JavaScript FIFO queue shared by Google Tag Manager, gtag.js, and Consent Mode V2. The most common failure is initialization order: if dataLayer is declared after the GTM container, data pushed during page load is silently lost. gtag() is simply a wrapper for dataLayer.push(arguments); GTM-specific commands (set, reset) require direct push() calls — they don't work via gtag().

Most explanations present dataLayer as a Google Tag Manager-specific structure. But window.dataLayer is also used by gtag(), and by Consent Mode V2. All three share the same object, which makes initialization order critical — wrong ordering causes silent data loss with no error in the console.

Which Tools Use dataLayer?

ToolRelationship to dataLayer
Google Tag ManagerReads dataLayer when the container loads; processes items as triggers, variables, and events
gtag.jsThe gtag() function is a wrapper for dataLayer.push(arguments)
Consent Mode V2gtag('consent', 'default', {...}) calls push to dataLayer

Google’s own gtag.js implementation1 defines the function as:

window.dataLayer = window.dataLayer || [];
function gtag() {
  dataLayer.push(arguments);
}

Every gtag() call goes through this object. So gtag('event', 'purchase', {...}) and dataLayer.push({event: 'purchase', ...}) use the same underlying array. The difference lies only in how Google’s own tags interpret the gtag format.

GTM’s Internal Model and Recursive Merge

The window.dataLayer array and GTM’s internal data model (abstract data model) are not the same thing. When GTM processes each push() call, it copies the data into its own internal model; GTM variables read from this model, not directly from the dataLayer array2.

This internal model is cumulative. Each push is applied to the existing model via recursive merge: primitive values (string, number) are replaced entirely; objects and arrays are merged key-by-key — existing keys are updated, keys absent from the new push are preserved.

dataLayer.push({ pageCategory: "product", ecommerce: { currency: "USD" } });
// Model: { pageCategory: 'product', ecommerce: { currency: 'USD' } }

dataLayer.push({ event: "view_item", ecommerce: { item_id: "123" } });
// Model: { pageCategory: 'product', ecommerce: { currency: 'USD', item_id: '123' }, event: 'view_item' }
// currency is still there — recursive merge, not overwrite

This behavior creates problems during UA-to-GA4 migrations. Legacy UA keys like eventCategory, eventAction, eventLabel, and transactionId accumulate in the model after being pushed by old GTM tags. New GA4 events don’t clear them, so they continue showing up alongside every event in GTM preview.

Two ways to remove a specific key from the model:

// Clear the ecommerce object before the next event — Google's recommended approach
dataLayer.push({ ecommerce: null });

// Remove UA remnants
dataLayer.push({
  eventCategory: undefined,
  eventAction: undefined,
  eventLabel: undefined,
});

The eventModel key: When GTM and gtag.js run on the same page, gtag’s event parameters appear in dataLayer under a top-level key called eventModel3. This key is created by the gtag.js runtime, not by GTM — it reflects how gtag.js stores its own internal parameters. If you see an eventModel block on an event in GTM preview, there is an active gtag.js implementation running on the page.

Initialization Order: Why Data Goes Missing

When the GTM container loads, it reads dataLayer immediately. If dataLayer is declared after the GTM snippet, any data pushed during page load is never seen by GTM.

Wrong order:

<!-- 1. GTM loads -->
<script>
  (function(w,d,s,l,i){...})(window,document,'script','dataLayer','GTM-XXXX');
</script>

<!-- 2. dataLayer push — GTM already loaded, this data is lost -->
<script>
  window.dataLayer = window.dataLayer || [];
  dataLayer.push({
    pageType: "product",
    userType: "member",
  });
</script>

Correct order:

<!-- 1. dataLayer FIRST -->
<script>
  window.dataLayer = window.dataLayer || [];
  dataLayer.push({
    pageType: "product",
    userType: "member",
  });
</script>

<!-- 2. GTM container SECOND -->
<script>
  (function(w,d,s,l,i){...})(window,document,'script','dataLayer','GTM-XXXX');
</script>

If delivering data before GTM is not possible (async loading, lazy rendering), use push() paired with gtm.dom or gtm.load triggers to re-process the data4.

dataLayer = [] vs dataLayer.push() — The Most Common Mistake

These two forms produce very different results, and confusing them is one of the most frequent causes of data loss.

// WRONG: Resets the object, erases everything GTM or gtag already added
dataLayer = [];

// WRONG: Same problem — overwrites existing state
window.dataLayer = [];

// CORRECT: Use existing if present, create if not — preserves prior data
window.dataLayer = window.dataLayer || [];
dataLayer.push({ pageType: "product" });

The dataLayer = [{...}] array literal syntax is only safe during page load, before GTM, for static page metadata. In all other situations, window.dataLayer = window.dataLayer || [] is the safer default.

// Static page data — before GTM, on page load
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
  pageCategory: "checkout",
  visitorType: "returning",
});

// After user interaction — always use push()
dataLayer.push({ event: "add_to_cart", item_id: "12345" });

dataLayer operates as a FIFO queue: items are processed in the order they arrive. Consent commands, however, are processed before other queued items5. This is why consent default must be declared before the GTM container, not just before other data pushes.

<!-- 1. Consent default — required before GTM -->
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag() {
    dataLayer.push(arguments);
  }

  gtag("consent", "default", {
    ad_storage: "denied",
    analytics_storage: "denied",
    ad_user_data: "denied",
    ad_personalization: "denied",
    wait_for_update: 500,
  });
</script>

<!-- 2. GTM container -->
<script>
  (function(w,d,s,l,i){...})(window,document,'script','dataLayer','GTM-XXXX');
</script>

When a user makes a consent choice in the banner, the CMP sends gtag('consent', 'update', {...}); GTM receives this update through dataLayer and fires or unblocks the relevant tags.

GTM-Specific Commands: set and reset

These two commands only work via direct dataLayer.push(). Sending them through gtag() will not be processed correctly by GTM.

// Write a value to GTM's internal model — does NOT work via gtag()
dataLayer.push(["set", "pageCategory", "product-detail"]);

// Reset GTM's internal model
dataLayer.push(["reset"]);

set writes a value directly to GTM’s internal model, making it readable as a variable in subsequent events. reset clears all model data accumulated during the session. Both are GTM-specific and relate only to the GTM variable system, not to Analytics or Ads tags6.

Defining Variables in GTM

In GTM, navigate to Variables > User-Defined Variables > New > Data Layer Variable to read values from dataLayer. Variable names are case-sensitive and must match exactly: pageCategory and pagecategory are different variables in GTM.

Data Layer Version should remain set to Version 2. Version 2 correctly reads nested objects (for example, ecommerce.items[0].item_name). Version 1 only handled flat key-value structures and produces incorrect results on nested paths4.

note

Ecommerce event formats (purchase, add_to_cart, view_item_list) are outside the scope of this article. For the GA4 ecommerce push structure, see GTM and E-Commerce Events.

Footnotes

  1. Google Tag (gtag.js). Google Developers
  2. Google Tag Manager’s Data Model. Simo Ahava
  3. Use gtag.js Parameters In Google Tag Manager. Simo Ahava
  4. Data layer. Tag Manager Help. Google 2
  5. Consent Mode. Google Tag Platform Developer Guide
  6. Developer Guide. Google Tag Manager. Google Developers
Key Takeaways
  • 01 window.dataLayer is one shared object: GTM, gtag.js, and Consent Mode V2 all write to and read from the same JavaScript array
  • 02 Initialization order is critical: dataLayer declaration and consent default must come before the GTM container script
  • 03 Writing dataLayer = [] deletes existing data; always use window.dataLayer = window.dataLayer || [] to preserve prior state
  • 04 gtag() is a direct wrapper for dataLayer.push(arguments); GTM-specific set and reset commands must use direct push(), not gtag()
  • 05 The Consent API is processed before other queued items in the FIFO order, which is why consent default must precede the GTM container
Frequently Asked Questions (FAQ)
+ Does dataLayer only work with Google Tag Manager?

No. The window.dataLayer object is shared by GTM, gtag.js, and Consent Mode V2. The gtag() function translates directly to dataLayer.push(arguments). All three tools write to and read from the same JavaScript object.

+ Why must dataLayer be declared before the GTM snippet?

GTM reads dataLayer the moment its container loads. If dataLayer is declared after GTM, any data pushed during page load — pageType, userType, and similar values — is lost. GTM starts firing tags without ever seeing that data.

+ What is the difference between dataLayer = [] and dataLayer.push()?

dataLayer = [] completely resets the object, deleting everything GTM or gtag has already added. Always use window.dataLayer = window.dataLayer || [] first, then append with push().

+ Are gtag() and dataLayer.push() the same thing?

Mostly, but not entirely. gtag() is a direct dataLayer.push(arguments) wrapper. However, GTM-specific commands like set and reset do not work when sent via gtag() — they require direct dataLayer.push(['set', ...]) or dataLayer.push(['reset']) calls.

+ Why does consent default need to be sent before the GTM container loads?

GTM needs to know consent status before firing its first tags. If consent default arrives after the container loads, GTM may fire tags in an unknown consent state, leading to incorrect tag behavior on the first page load.

+ Which Data Layer Version should I use in GTM — Version 1 or Version 2?

Always use Version 2. Version 2 correctly reads nested objects (for example, ecommerce.items[0].item_name). Version 1 only handled flat key-value structures and produces incorrect results with nested paths.