Skip to main content

The GTM Container Audit Checklist We Run Every Quarter

Paused tags, zombie triggers, unused variables, consent gaps, and performance weight: the five-part sweep that keeps containers healthy.

Every container we inherit tells roughly the same story. Somebody set it up carefully, and then two years of campaign launches, agency handovers, and "just ship it before Friday" requests piled on top. Nothing looks broken from the outside. Tags fire, reports populate, dashboards refresh. Nobody notices anything is wrong until a conversion number stops matching the invoice, or a legal review asks which vendors receive data before consent and the honest answer is "we'd have to check".

The fix isn't a heroic one-time cleanup project. It's a recurring sweep on the calendar, run the same way every quarter so the findings stay comparable. Five parts, a fixed order, and a rule that everything found either becomes a ticket with a name on it or gets deleted before you close the tab. Here's the checklist, the specific steps for each part, and what to keep between runs.

Why Containers Rot Between Quarters

Containers don't decay randomly. They decay in a small number of predictable ways, which is exactly why a checklist beats improvising each time.

The first source is temporary work that never expires. A remarketing pixel goes in for a two week promo. The promo ends, the tag stays, because removing it was nobody's job. GTM has no expiry field, no owner field, and no "why does this exist" field, so in practice publishing is append only.

The second is drift between the container and the site. Front end teams rename CSS classes or move checkout to a client-side route transition. Nothing in GTM breaks loudly. A click trigger built on .btn-checkout-primary simply stops matching, and the event count slides to zero without an error anywhere.

Third, the consent surface keeps moving. Consent Mode v2 added ad_user_data and ad_personalization. A CMP upgrade changes how categories map to signals. Tags configured correctly eighteen months ago are now configured against a model that no longer exists.

Fourth, people move on. Contractors finish, agencies get replaced, developers change jobs, and their publish rights outlive their badge. A quarterly cadence catches all four before they compound.

Taking Inventory of Dead Weight

Start with a container export, not the UI. Go to Admin, then Export Container, and pick the currently live version rather than a workspace. You want the thing that's actually running. Save it with the date in the filename.

Then run something over it. Clicking through 180 tags in the web interface is how audits get abandoned halfway. A short script gives you the three lists you need in about a second:

// audit-inventory.js
// Usage: node audit-inventory.js GTM-XXXXXXX_v41.json
const fs = require('fs');
const v = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')).containerVersion;

const tags = v.tag || [], triggers = v.trigger || [], variables = v.variable || [];
const blob = JSON.stringify({ tags, triggers, variables });

const used = new Set(tags.flatMap(t => [
  ...(t.firingTriggerId || []),
  ...(t.blockingTriggerId || [])
]));

console.log('PAUSED TAGS');
tags.filter(t => t.paused).forEach(t => console.log('  ' + t.name));

console.log('TRIGGERS ATTACHED TO NOTHING');
triggers.filter(t => !used.has(t.triggerId)).forEach(t => console.log('  ' + t.name));

console.log('VARIABLES REFERENCED NOWHERE');
variables
  .filter(x => blob.split('{{' + x.name + '}}').length < 2)
  .forEach(x => console.log('  ' + x.name));

console.log('TAGS ON ALL PAGES: ' +
  tags.filter(t => (t.firingTriggerId || []).includes('2147479553')).length);

Paused tags are where tags go to die. Our rule: anything paused across two consecutive audits gets deleted rather than un-paused. Version history keeps the definition forever, so deletion costs you nothing except the ability to procrastinate.

Orphan variables are usually harmless but they make every future change slower, because the next person has to read them to find out they don't matter. The string match above catches references inside Custom HTML and Custom JavaScript too, which a naive check would miss.

Dead selector triggers need one more step, because the export can't tell you whether a selector still matches anything. Take the CSS selector from each click and element visibility trigger, open the page it's meant to fire on, and run document.querySelectorAll('.btn-checkout-primary').length in the console. Zero means the trigger has been dead since the rename. Cross-check against GA4: an event with zero hits in 30 days on a page that still gets traffic is a dead trigger, not a quiet quarter.

Firing Health and Silent Duplicates

This is the part that pays for the whole exercise. A conversion tag firing four times per transaction doesn't throw an error, doesn't show up in Tag Assistant as a problem, and quietly trains Smart Bidding on numbers that are four times too generous.

Duplicates come from a handful of causes. A tag with two triggers where both conditions match. A legacy hardcoded gtag snippet on the confirmation page alongside the GTM version. A single-page app where the history change trigger fires on the route transition and the purchase dataLayer push fires separately. Or a confirmation page users can refresh, with no transaction ID sent to deduplicate.

The quickest detection doesn't involve GTM at all. Pull backend order counts for a settled seven day window and compare against GA4 purchase count and the Google Ads conversion count for the same window. You're looking at the ratio, not the absolute number. Anything meaningfully above 1.0 in Ads with a clean GA4 number usually means the Ads tag lacks a Transaction ID and is counting refreshes.

Then reproduce it. Complete a real transaction in GTM Preview on staging and click through every event in the left rail, not just the one labelled purchase. Duplicates hide on the adjacent events: a gtm.historyChange that fires the conversion a second time two hundred milliseconds later is easy to miss if you only inspect the row you expected.

Check the opposite failure in the same pass. Tags that fire far less than they should are usually consent gated behind a banner that broke, or gated behind a trigger exception somebody added for a test and never removed. If your accepted rate dropped from typical to something implausible after a CMP update, the tags are fine and the banner is the bug.

Consent Coverage With No Exceptions

Open the Tags list and click the shield icon in the top right to switch on the consent overview. It shows every tag and whether consent has been configured, which is the fastest way to find the gaps. Sort by "Not set" and work through that list.

Google tags read ad_storage and analytics_storage on their own. Everything else needs Additional Consent Checks set explicitly, and the tags that get missed are almost always Custom HTML. Somebody pasted a LinkedIn Insight or TikTok pixel into a Custom HTML tag two years ago. It has no consent awareness at all. It fires on page load regardless of the banner, and the container summary still looks compliant because the Google tags are configured properly.

Configuration is not verification. Set the banner to deny everything, hard reload, and watch the Network panel. For non-Google vendors, denial should mean no outbound request at all: nothing to facebook.com/tr, snap.licdn.com, or analytics.tiktok.com. For Google tags, you should see the request go out carrying gcs=G100, which is Consent Mode working as designed rather than a leak.

Two things people skip. Check all four signals, not just ad_storage, since Enhanced Conversions and audience building depend on ad_user_data and ad_personalization separately. And check the region overrides in the consent defaults if you serve multiple markets, because a default granted for everything with a narrow EEA override is one country list edit away from being wrong.

What the Container Costs to Load

Every quarter, count the tags firing on All Pages. That number should be small and each one should justify itself. A heatmap tool that only matters on three product pages does not need to load on the password reset screen.

Look specifically for Custom HTML tags that inject synchronous third-party scripts, and anything containing document.write, which blocks parsing outright. Open the Performance panel in DevTools, record a load, and look for long tasks attributed to gtm.js or to the scripts it appends. That's the honest measure, better than counting tags.

Then ask which tags could move server-side. Good candidates share a pattern: their only real job is delivering a payload to a vendor endpoint, and they gain nothing from running in the browser. Meta CAPI, the TikTok Events API, GA4 Measurement Protocol, and most CRM webhooks all fit. Moving them to a server container removes their weight from the page and makes them resilient to tracking prevention at the same time.

Bad candidates are anything that reads the DOM, renders UI, or needs browser context: session recording, heatmaps, A/B testing, chat widgets. Don't move those. Decide whether they still earn their place instead.

Access Review and Stale Permissions

Five minutes, twice the value of an hour spent anywhere else. Admin, then User Management, at both the account and the container level, because they're separate. Account level controls who can see the account and create containers. Container level controls Read, Edit, Approve, and Publish.

What you're looking for: personal Gmail addresses instead of company domains, agency staff from an engagement that ended, developers who left, and anyone with Publish who has never published. Publish should be a short named list. Approve is the useful middle setting for people who do the work but shouldn't push it live alone.

Two places that get overlooked. Service accounts with GTM API access, which live in Google Cloud rather than the GTM interface and never appear in a user list review. And linked accounts: the GA4 property links and Google Ads links attached to the container, which grant a path to data that survives a user being removed.

FindingWhy it mattersDisposition
Conversion tag firing more than once per orderInflated conversions, bidding trained on noiseFix this week
Custom HTML pixel with no consent checkVendor receives data before consent, in every marketFix this week
Click trigger matching a class that no longer existsSilent data loss, no error anywhereCurrent sprint
Publish access for someone who leftUnreviewed changes to productionSame day
Tag paused across two auditsClutter that slows every future changeQuarterly cleanup version
Variable referenced nowhereSameQuarterly cleanup version

Turning Findings Into Owned Tickets

The classic failure is a beautiful audit document that nobody reads. The document is not the deliverable. Tickets are.

Every finding gets four fields: a one line description in plain language, a severity from the table above, an owner who is a named person rather than a team, and a date. Anything missing an owner and a date is a note, not a task, and you should either fix it yourself in the next ten minutes or drop it.

Route the cruft differently from the bugs. Data corrupting problems and consent gaps go into whatever tracker the engineering team actually uses, with the reproduction steps attached. Deletions get batched into a single GTM version named for the quarter, with a version description that lists every removed item. One version, one rollback, one line in the change log. Twelve separate cleanup publishes is how you lose the ability to answer "what changed on the 14th".

Write the version description properly. "Q1 audit cleanup: removed 11 paused tags, 6 orphan variables, 3 dead click triggers. Full list in AUDIT-2026-Q1." Six months later that sentence is the only thing standing between you and an afternoon of archaeology.

Artifacts Worth Keeping Between Audits

Four things, and they compound. Keep the dated container exports somewhere durable, in the repo or a shared drive. Diffing two JSON exports is by far the fastest answer to "what changed since last quarter", and it takes seconds compared to reading version history entry by entry.

Keep a tag register. A spreadsheet, honestly, with tag name, purpose in one sentence, business owner, vendor, consent category, and a review date. GTM has nowhere to record why a tag exists, which is the single most useful fact about it. The register is also the artifact you hand to legal when they ask which third parties receive data.

Keep last quarter's findings with their resolution status. Every audit should open by checking whether the previous one's tickets actually shipped. If half of them are still open, the problem isn't the audit method, it's that nobody owns the follow-through, and that's a different conversation to have.

Keep the naming convention where people will read it, and enforce it at review time. A container where every tag reads GA4 - Event - add_to_cart or Ads - Conversion - Lead is faster to audit than one with freeform names, every single quarter, forever.

Making the Sweep Boring

The first run of this checklist on a neglected container is unpleasant. You'll find forty things, several of them embarrassing, and at least one that's been corrupting a number somebody reports to the board. That's normal, and it's the point.

The fourth run finds five things and takes an afternoon. That's the actual goal: a maintenance routine dull enough that it survives staff changes and busy quarters. Put it on the calendar for the week after quarter close, when the reporting rush is done and nobody is mid-launch. Then treat it like any other recurring obligation, which is to say, do it whether or not anything feels wrong.