💡 Why toDigital MarketingIntermediate✨ AI-assisted

Why a UTM Naming Convention Decides If AI Reporting Works

WittyTech Daily··5 min read
#utm#data-quality#attribution#sql

Ask an AI assistant which channel drove last quarter's signups and it will hand you a confident table that lists Facebook, facebook and fb as three separate channels. The model isn't broken. Your utm_source values are. A written UTM naming convention, enforced at the moment a link is created, is the cheapest fix for AI reporting you will ever ship.

Why inconsistent utm_source values break AI reporting

UTM parameters are the tags on a campaign link (utm_source, utm_medium, utm_campaign) that tell your analytics where a visit came from. They are free text, and Google Analytics documents that parameter values are case sensitive: utm_source=google is different from utm_source=Google.

So one person tags a post Facebook, another tags an ad facebook, an agency uses fb, and a newsletter template says facebook.com. Four spellings, one channel, four rows. Each row looks small, so your biggest channel can rank fourth.

A dashboard at least shows you the ugly rows, and a human squints and notices. An AI summary narrates right over them. It either reports four small channels or merges some on a hunch, and the hunch can change between runs. Either way the headline is wrong and sounds certain.

The same thing happens to Email versus email, paid-social versus paid_social, and a campaign name with a trailing space. Campaign data quality is the ceiling on every report built above it.

Write the UTM naming convention down

A convention is a one-page contract: closed lists for the values that define channels, a pattern for the values that name things, and one named owner. Keep it boring.

utm_convention:
  version: 1
  owner: marketing-ops
  format: lowercase, underscores only, no spaces, hyphens or dots
  utm_source:        # closed list, one entry per platform
    - facebook
    - linkedin
    - google
    - newsletter
  utm_medium:        # closed list, one entry per channel type
    - paid_social
    - organic_social
    - cpc
    - email
  utm_campaign: <year>q<quarter>_<name>    # example: 2026q4_product_launch
  utm_content: <slug>                      # example: hero_video_a

Three rules do most of the work: lowercase only, underscores as the only separator, and closed lists for source and medium so nobody invents fb. Campaign names get a pattern instead of a list because there are too many of them, but the pattern still makes 2026q4_product_launch the only shape that passes.

Audit your warehouse for inconsistent values

Before you argue for the convention, measure the damage. The first query groups every raw utm_source by its lowercase, trimmed form and keeps only the ones with more than one spelling. Table names are placeholders: marketing_sessions holds one row per session with the UTM columns. This is Postgres-style SQL; other warehouses may call STRING_AGG something like LISTAGG and write the date filter differently.

SELECT
  LOWER(TRIM(utm_source))                                     AS clean_source,
  COUNT(DISTINCT utm_source)                                  AS spellings,
  STRING_AGG(DISTINCT utm_source, ' | ' ORDER BY utm_source)  AS raw_values,
  COUNT(*)                                                    AS sessions
FROM marketing_sessions
WHERE session_date >= CURRENT_DATE - INTERVAL '90 days'
  AND utm_source IS NOT NULL
GROUP BY LOWER(TRIM(utm_source))
HAVING COUNT(DISTINCT utm_source) > 1
ORDER BY sessions DESC;

This catches Facebook versus facebook (with a trailing space) but not fb, because no amount of lowercasing turns one into the other. For that, put your allowed list in a table and look for anything that isn't on it.

SELECT
  s.utm_source,
  COUNT(*) AS sessions
FROM marketing_sessions s
LEFT JOIN allowed_utm_sources a
  ON s.utm_source = a.value
WHERE s.session_date >= CURRENT_DATE - INTERVAL '90 days'
  AND s.utm_source IS NOT NULL
  AND a.value IS NULL
GROUP BY s.utm_source
ORDER BY sessions DESC;

Watch the comparison in the join: if your warehouse compares text case-insensitively by default, Facebook will match facebook and slip through. Sort by sessions and the top rows are your first cleanup list. Show both result sets to whoever signs off on the convention; they sell it better than any slide.

Enforce the convention where links are created

A document nobody checks is a wish. Put the check at the one place every link passes through: the link builder. If your team builds links in a spreadsheet, a dropdown fed by the allowed lists does the same job. If links are generated in code, reject bad values before they leave the building.

import re
from urllib.parse import urlencode, urlsplit, urlunsplit

ALLOWED_SOURCES = {'facebook', 'linkedin', 'google', 'newsletter'}
ALLOWED_MEDIUMS = {'paid_social', 'organic_social', 'cpc', 'email'}
SLUG = r'[a-z0-9]+(_[a-z0-9]+)*'
CAMPAIGN_RE = re.compile(r'^[0-9]{4}q[1-4]_' + SLUG + '$')
CONTENT_RE = re.compile('^' + SLUG + '$')


def build_tracked_url(base_url, source, medium, campaign, content=None):
    if source not in ALLOWED_SOURCES:
        raise ValueError(f'utm_source {source!r} not in {sorted(ALLOWED_SOURCES)}')
    if medium not in ALLOWED_MEDIUMS:
        raise ValueError(f'utm_medium {medium!r} not in {sorted(ALLOWED_MEDIUMS)}')
    if not CAMPAIGN_RE.match(campaign):
        raise ValueError(f'utm_campaign {campaign!r} must look like 2026q4_product_launch')
    params = {'utm_source': source, 'utm_medium': medium, 'utm_campaign': campaign}
    if content:
        if not CONTENT_RE.match(content):
            raise ValueError(f'utm_content {content!r} must be a lowercase slug')
        params['utm_content'] = content
    parts = urlsplit(base_url)
    query = '&'.join(filter(None, [parts.query, urlencode(params)]))
    return urlunsplit(parts._replace(query=query))

The function raises on Facebook, fb or 2026-launch, so the bad value never reaches an ad, an email or a post. Checks after the fact only tell you the data is already wrong.

The objection: "the AI can clean it up later"

It can, partly, and it's a fair thing to try. An AI can read your distinct values and propose that fb and Facebook are the same thing. That is useful once, to build a mapping for historical data.

As a standing plan it falls short in three ways:

  • It's a guess. Is social the same as paid_social? Is newsletter an email or a partner placement? The AI can't know. You do.
  • It's inconsistent. Cleanup done inside each report means two reports can merge differently and disagree, and nobody can say which one is right.
  • It can't recover what was never captured. If every link says utm_medium=email, whether it was a promotion or a receipt, no model can separate them afterwards.

The sound version: the AI proposes the mapping, a human approves it, and the approved mapping lives in a table that every report joins to. That table is your convention again, applied to the past.

Start with one query

Run the first query over the last 90 days and pick the worst offender. Then write down your sources and mediums, put the check in your link builder, and tag everything new from that day. Old data can be mapped later; new data should never need mapping.

AI reporting in production is only as trustworthy as the campaign fields beneath it, so fix the tags where links are created, where it costs nothing, instead of in every report, where it costs trust.

← More in Digital Marketing