Finding Changes to a GTM Tag: Comparing Saved and Published Versions

Management Summary
Google Tag Manager keeps versions of the settings used to measure a website. Comparing those versions helps locate when a measurement component changed, even if its name changed too. This is useful when a report shifts after a release and the cause is unclear. A saved version and the version actually published are not the same fact, so the period of live use matters. Finding a changed setting narrows the investigation but does not by itself prove that the change caused the business figures to move.
Contents
A metric changes after several GTM releases. Two separate questions help narrow down the cause: Which saved version changed a tag’s configuration? And when was that version actually active on the affected website? A comparison by tagId answers the first question. Publication history and the selected environment answer the second.
The workflow is to retrieve the complete versions, follow the same tag across those versions and inspect the relevant transitions. Triggers, variables and actual behaviour then require a separate check.

1. Saved versions and published versions
A GTM version is a saved configuration. A version can exist without publication, and an older version can be published again. Sorting by version number therefore does not produce a complete timeline of live operation. For an incident, the publication time, environment and container actually installed on the site matter. The GTM interface maintains a publish history.
A meaningful version name helps with orientation. The details and change comparison still need inspection. Since September 2026, GTM can suggest an AI-generated version summary; that summary does not replace a configuration review.
2. Retrieval of complete versions
The API method version_headers.list returns version IDs, names and element counts, among other fields. Individual tag fingerprints are absent. Comparing tags requires the complete responses from versions.get. A nextPageToken indicates more headers, so a single list request is not a reliable archive.
The prerequisites are Python 3, an enabled Tag Manager API and a valid OAuth access token with https://www.googleapis.com/auth/tagmanager.readonly. The authenticated identity also needs access to the relevant GTM container. Numeric API IDs differ from public identifiers such as GTM-XXXX. Configurations and tokens belong in a protected working directory.
The following example is saved as archive.py. The token is supplied through the GTM_ACCESS_TOKEN environment variable, without inclusion in source code or public logs. The output directory must be new. The code follows all pages, checks account and container identity and writes complete.json only after every download succeeds. Deleted versions are explicitly excluded.
import json
import os
import re
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlencode
from urllib.request import Request, urlopen
BASE = "https://tagmanager.googleapis.com/tagmanager/v2/"
def check_version(data, account, container, version):
expected = {"accountId": account, "containerId": container,
"containerVersionId": version}
if not isinstance(data, dict) or any(data.get(k) != v for k, v in expected.items()):
raise ValueError("Unexpected container/version identity")
if "error" in data or data.get("deleted", False) is not False:
raise ValueError("Error response or deleted version")
tags = data.get("tag", [])
if not isinstance(tags, list):
raise ValueError("Invalid tag list")
ids = []
for tag in tags:
if not isinstance(tag, dict) or not isinstance(tag.get("tagId"), str):
raise ValueError("Invalid tag")
ids.append(tag["tagId"])
if len(ids) != len(set(ids)):
raise ValueError("Duplicate tag IDs")
return data
def collect(account, container, output, get):
if not all(re.fullmatch(r"[0-9]+", x) for x in (account, container)):
raise ValueError("Numeric API account/container IDs required")
output = Path(output)
output.mkdir(mode=0o700) # Existing directory is an error.
parent = f"accounts/{account}/containers/{container}"
versions, seen_tokens, token = set(), set(), ""
while True:
query = {"includeDeleted": "false"}
if token:
query["pageToken"] = token
page = get(parent + "/version_headers?" + urlencode(query))
if not isinstance(page, dict) or "error" in page:
raise ValueError("Invalid headers response")
headers = page.get("containerVersionHeader", [])
if not isinstance(headers, list):
raise ValueError("Invalid headers list")
for item in headers:
if not isinstance(item, dict):
raise ValueError("Invalid header")
version = item.get("containerVersionId", "")
if (item.get("accountId") != account or item.get("containerId") != container
or not isinstance(version, str) or not re.fullmatch(r"[1-9][0-9]*", version)
or item.get("deleted", False) is not False or version in versions):
raise ValueError("Unexpected or duplicate version header")
versions.add(version)
token = page.get("nextPageToken", "")
if not isinstance(token, str) or token in seen_tokens:
raise ValueError("Invalid/repeated page token")
if not token:
break
seen_tokens.add(token)
for version in sorted(versions, key=int):
data = check_version(get(parent + "/versions/" + version), account, container, version)
temporary = output / ("version-" + version + ".tmp")
with temporary.open("x", encoding="utf-8") as handle:
json.dump(data, handle, ensure_ascii=False, indent=2)
temporary.replace(output / ("version-" + version + ".json"))
manifest = {"accountId": account, "containerId": container,
"versions": sorted(versions, key=int),
"completedAt": datetime.now(timezone.utc).isoformat()}
(output / "complete.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
return manifest
def main():
account, container, output = sys.argv[1:]
token = os.environ["GTM_ACCESS_TOKEN"]
os.umask(0o077)
def get(path):
time.sleep(5) # Other clients share the project's quota.
request = Request(BASE + path, headers={"Authorization": "Bearer " + token})
with urlopen(request, timeout=30) as response:
return json.load(response)
collect(account, container, output, get)
if __name__ == "__main__":
main()
python3 archive.py 6000000001 7000000002 archive-20261024
An HTTP error, expired token, invalid JSON or unexpected identity stops the run. Some files may already exist, but without complete.json the archive is incomplete. After resolving the cause, a new run starts in a new directory. An unknown error object is never reused as a valid container version.
The documented standard limits are 10,000 requests per project per day and 25 requests within a rolling 100-second window. The example waits five seconds before each request. Other clients consume the same project quota, so the delay cannot guarantee success. Google documents HTTP 403 for quota exhaustion. The specific error determines the remedy; a blanket block until the end of the hour is not documented.

3. Changes to the same tag
Renaming an existing tag changes its name, not its identity. Selection therefore uses tagId within the same account and container. The API fingerprint is a change identifier computed by GTM. The documentation describes recalculation after modifications, without promising a cryptographic checksum or proof of changed measurement behaviour.
tag_history.py resides beside archive.py. The invocation below examines example tag 25. The example accepts only the archive format produced by the preceding script, not differently wrapped JSON exports from the interface.
import difflib
import json
import sys
from pathlib import Path
from archive import check_version
def analyse(directory, tag_id):
directory = Path(directory)
manifest = json.loads((directory / "complete.json").read_text(encoding="utf-8"))
versions = manifest["versions"]
if not versions or len(set(versions)) != len(versions):
raise ValueError("Empty or duplicate version manifest")
previous, first, rows, diffs = None, True, [], []
for version in sorted(versions, key=int):
if not isinstance(version, str) or not version.isascii() or not version.isdecimal():
raise ValueError("Invalid version ID")
data = json.loads((directory / f"version-{version}.json").read_text(encoding="utf-8"))
check_version(data, manifest["accountId"], manifest["containerId"], version)
tag = next((t for t in data.get("tag", []) if t["tagId"] == tag_id), None)
if tag is not None and (not isinstance(tag.get("fingerprint"), str) or not tag["fingerprint"]):
raise ValueError("Missing tag fingerprint")
state = ("BASELINE_PRESENT" if tag is not None else "BASELINE_ABSENT") if first else (
"ADDED" if previous is None and tag is not None else
"REMOVED" if previous is not None and tag is None else
"ABSENT" if tag is None else
"CHANGED" if previous["fingerprint"] != tag["fingerprint"] else "UNCHANGED")
rows.append((version, state))
if not first and state in {"ADDED", "REMOVED", "CHANGED"}:
def normalise(value):
if value is None:
return "null\n"
metadata = {"path", "accountId", "containerId", "workspaceId", "fingerprint", "tagManagerUrl"}
return json.dumps({k: v for k, v in value.items() if k not in metadata},
ensure_ascii=False, sort_keys=True, indent=2) + "\n"
diffs.append("".join(difflib.unified_diff(
normalise(previous).splitlines(keepends=True),
normalise(tag).splitlines(keepends=True),
fromfile="previous saved version", tofile="version " + version)))
previous, first = tag, False
return rows, diffs
if __name__ == "__main__":
rows, diffs = analyse(sys.argv[1], sys.argv[2])
for version, state in rows:
print(version, state)
for diff in diffs:
print(diff)
python3 tag_history.py archive-20261024 25
BASELINE_PRESENT and BASELINE_ABSENT describe only the first examined version. ADDED means a transition from absent to present; REMOVED means the reverse. CHANGED indicates a different fingerprint and UNCHANGED an equal one. ABSENT means continued absence. Reappearance with the same fingerprint therefore remains visible.
A result such as “version 44: CHANGED” narrows down a transition between two saved configurations. Missing versions, unrecorded intermediate edits or an unavailable beginning limit the conclusion. The first available version does not automatically establish the original creation time of the tag.
4. Interpretation of the difference
For relevant transitions, the script produces a text diff: a comparison showing added and removed lines. Only the explicitly listed administrative fields at the top tag level are omitted. Names, notes and parameters remain. Sorting normalises object keys while preserving array order. A rename therefore still appears as a configuration difference. An empty diff despite a new fingerprint can result from omitted metadata.
Relevant checks include parameter values, triggers that start or prevent a tag, paused status, consent settings, setup tags and teardown tags. An unchanged tag can behave differently following a change to a referenced variable, trigger, template or external data value. Investigation must therefore extend beyond the main tag’s fingerprint. Comparing complete containers helps identify further dependencies, but does not automatically establish their behavioural effect.
For larger comparisons, normalising two GTM exports before the diff extends this workflow. Different export formats and meaningful array order need particular attention.

5. Configuration changes and causes
A parameter changed at the relevant time is a lead for investigation. Establishing a causal connection with a metric also requires controlled functional testing, for example in a test environment with Tag Assistant, plus relevant network and event data. The person publishing a version and the author of every individual edit are also different concepts.
Since the GTM update of 8 October 2026, visible gtag.config events can reach wildcard triggers; initialisation occurs when the container loads. Such Google runtime changes cannot be explained solely by comparing saved tags. The separately offered container optimisation can introduce workspace changes. Google explicitly states that permissions are not versioned: rollback does not restore earlier access rights. The overview of the 2026 GTM and Google tag changes explains this distinction.
Questions and answers
Does a successful retrieval establish a complete historical archive?
The archive covers non-deleted versions available to the authenticated identity during retrieval. The requests run one after another, so the archive does not capture all versions at exactly the same moment. Concurrent changes or deletions can cause a failure or a collection spanning different points in time. A quiet maintenance window and an additional check against the version list improve traceability. The manifest’s completion time is not a publication date.
Can an existing file be treated as permanently current?
A file records the response at a particular retrieval time. Version metadata can change; the API provides a version update operation. An archive should therefore record where each response came from and when each response was retrieved. A filename alone proves neither a valid response nor a complete, current collection.
Why stop on missing files or fingerprints?
Silently skipping missing data could turn an incomplete archive into apparently unchanged history. Stopping distinguishes a technical error from the genuine absence of the target tag. Validation covers identity and the structure needed for this comparison, not every possible GTM field.
Does the same fingerprint prove identical behaviour?
No. The comparison concerns the saved tag. Dependencies, website code, consent state, data and subsequently loaded runtime software can affect behaviour. A fingerprint replaces neither investigation of those factors nor a functional test.
Sources and further documentation
- Publishing, versions, and approvals
- version_headers.list
- ContainerVersionHeader
- versions.get
- ContainerVersion
- Tag
- API limits and quotas
- API authorization
- versions.update
- GTM release notes
- Container optimisation
- Unified Google tag configuration
- Supported Google tag installation
- GTM initialisation
- GTM Web


