UTM parameters, properly
The five parameters, the mistakes that break attribution, why a short link must forward UTMs rather than swallow them, and a taxonomy that survives a team.
- marketing
- analytics
- links
UTM parameters are the most widely used and least carefully governed piece of infrastructure in marketing. They are five query-string keys that any tool can read and any human can typo, and the typos do not fail loudly — they just quietly create a second row in your report.
This is a practical guide to getting them right, and to one thing specific to short links that almost nobody thinks about until attribution breaks.
The five parameters
They are a fossil. "UTM" stands for Urchin Tracking Module; Urchin was a web analytics company Google acquired in 2005, and what became Google Analytics inherited its query-parameter convention. Two decades later almost every analytics product reads them, because not reading them would be commercially suicidal.
| Parameter | Question it answers | Example |
|---|---|---|
utm_source | Which specific property sent the traffic? | newsletter, linkedin, partner-acme |
utm_medium | What kind of channel is it? | email, cpc, social, qr |
utm_campaign | Which coordinated effort is this part of? | 2026-q3-launch |
utm_term | Which paid keyword? | link+shortener |
utm_content | Which creative or placement, within the campaign? | hero-cta, footer-link, variant-b |
Only utm_source is required by Google Analytics; in practice source and medium together are the pair that everything else is built on, because they feed the default channel grouping. utm_campaign is what a human reads in a report. utm_term and utm_content are the ones people use for whatever they like, which is fine as long as "whatever they like" is written down.
GA4 also recognises a handful of newer keys — utm_id, utm_source_platform, utm_creative_format, utm_marketing_tactic — but support outside GA4 is patchy. Treat them as a GA4 feature, not a standard.
The mistakes
1. Confusing source with medium
The single most common error, and it produces reports that look fine and mean nothing:
?utm_source=newsletter&utm_medium=newsletter WRONG
?utm_source=mailchimp&utm_medium=email RIGHTMedium is a small, closed set — the kind of traffic. Source is the specific property. If you find yourself writing the same word into both, one of them is wrong.
2. Free-text medium
email, Email, e-mail, mail, newsletter-email are five rows in your report and one channel in reality. utm_medium should be an enum with fewer than ten members. Ours:
email · social · cpc · display · affiliate · referral · qr · print · sms · organic-socialIf a campaign genuinely does not fit, the answer is to have a conversation and then add a member — not to invent one at link-creation time.
3. Inconsistent case
Different tools disagree about case folding, and the disagreements are not documented anywhere you can rely on. GA4's default channel grouping lowercases for matching, but the values you see in explorations and in BigQuery exports are the ones that were sent. The only policy that survives contact with more than one tool is to lowercase everything yourself, before the link exists. Enforce it in the builder, not in a style guide.
4. Tagging internal links
Putting UTMs on a link from one page of your own site to another page of your own site is the classic self-inflicted wound. In session-based analytics it starts a new session and re-attributes the visitor to the internal campaign, which means a genuine acquisition from paid search gets overwritten by utm_source=homepage-banner. Your acquisition report then attributes conversions to your own homepage.
Internal navigation should be measured with events, never with UTMs.
5. Personal data in utm_content
?utm_content=user_4471_ayse%40example.com WRONGThis happens when someone builds per-recipient links to measure who clicked. It is a data leak with unusual reach: query strings end up in server access logs, in the Referer header sent onward to third-party assets on the landing page, in browser history, in analytics products with their own retention rules, and — for a short link — in whatever the shortener stores. Use an opaque per-recipient token that only your own system can resolve, or measure at the aggregate level.
6. Parameters after the fragment
https://example.com/pricing#plans?utm_source=newsletter WRONG
https://example.com/pricing?utm_source=newsletter#plans RIGHTEverything after # is the fragment and is never sent to the server. It is not a query string that happens to be late; it is not a query string at all. This one is easy to spot in review and impossible to spot in a report, because the traffic simply arrives as direct.
The short-link problem: forwarding
Here is the part that is specific to shorteners, and where a lot of them get it wrong.
A short link sits between the click and the destination. When someone opens gecis.in/kampanya?utm_source=newsletter&utm_medium=email, there are two independent things that want those parameters:
- The shortener, which records the campaign against the click so you can see the breakdown per source without touching the destination site.
- The destination, whose own analytics — GA4, Plausible, a server-side pipeline, whatever — will attribute the session to
directif the parameters do not arrive.
A shortener that reads the UTMs and then redirects to the bare target has swallowed them. Your short-link dashboard shows a beautiful campaign breakdown; the destination's dashboard shows a wall of direct traffic; and the two numbers can never be reconciled. Worse, it fails silently, months after the campaign, when someone finally asks why paid social shows no revenue.
The rule is simple: the shortener must forward the query string it received.
// lib/utm.ts — the visitor's parameters ride along; `qr` is ours and is stripped.
export function forwardQuery(params: URLSearchParams): string {
const forwarded = new URLSearchParams(params)
forwarded.delete('qr')
const encoded = forwarded.toString()
return encoded === '' ? '' : `?${encoded}`
}And then one subtlety that matters more than it looks:
// lib/redirect.ts
export function buildTargetUrl(targetUrl: string, incoming: URLSearchParams): string {
const forwarded = forwardQuery(incoming)
if (forwarded === '') return targetUrl
try {
const url = new URL(targetUrl)
for (const [key, value] of new URLSearchParams(forwarded.slice(1))) {
// A parameter the stored target already sets WINS.
if (!url.searchParams.has(key)) url.searchParams.append(key, value)
}
return url.toString()
} catch {
return targetUrl
}
}The incoming parameters are appended, never overwritten. If the stored target is https://shop.example/checkout?plan=pro, nobody can turn it into plan=free by appending ?plan=free to the short link. Precedence has to run in that direction: the person who created the link chose the destination, and the person who clicks it must not be able to rewrite it. A shortener that lets the click win is a parameter-injection primitive pointed at somebody else's site.
Two places a UTM can live
This distinction confuses people, so it is worth being explicit. With a short link you can put campaign parameters in either of two places, and they behave differently:
Baked into the target at creation time. gecis.in/xY7k2Qp → https://example.com/?utm_source=newsletter&utm_medium=email. One short link, one campaign, forever. Good for a link that lives in exactly one place.
Appended to the short link at click time. gecis.in/xY7k2Qp?utm_source=newsletter → the parameters are recorded against the click and forwarded. One short link, many campaigns, and the shortener's own report can break it down by source. Good for a link that goes in the newsletter, the tweet and the printed poster.
The second is more powerful and slightly less safe: anyone who has the short link can append any parameters they like, so utm_source in your click table is self-reported by whoever built the URL and should be treated as a label, not as a measurement. It is trivially forgeable and occasionally forged by accident when someone copies a tagged link out of an email and shares it.
Which brings up the third source we track separately, precisely because it cannot be forged the same way: the QR trigger.
export function extractTrigger(params: URLSearchParams): ClickTrigger {
const qr = params.get('qr')
return qr === '1' || qr === 'true' ? 'qr' : 'link'
}?qr=1 is appended by us to the URL encoded into the QR image, so it is stripped before forwarding rather than passed on — it is our bookkeeping, not the destination's business. It is still only as trustworthy as the person who types the URL by hand, but nobody does that.
A taxonomy that survives a marketing team
Any convention works on paper. The ones that survive have three properties: they are short, they are enforced by a tool, and they have an owner.
Ours:
utm_source lowercase, [a-z0-9-], the property's name linkedin, partner-acme
utm_medium lowercase, from the closed list above email
utm_campaign lowercase, <yyyy>-<q|mm>-<theme> 2026-q3-launch
utm_content lowercase, <placement>-<variant> hero-cta-b
utm_term lowercase, keyword with + for spaces link+shortenerRules that do the heavy lifting:
- Hyphens inside a token, nothing else between tokens. Pick one separator and never mix. Underscores in values interact badly with the underscores in the parameter names when someone greps.
- Dates in the campaign, always, as
YYYY-QnorYYYY-MM. Sort order becomes chronological order for free, and two years later you can still tell whichlaunchthis was. - No spaces, ever.
%20and+are both legal and different tools normalise them differently. - No PII, no internal IDs that map to a person.
- The campaign name is a name, not a sentence. If it needs more than four words, the extra words belong in
utm_content.
And the enforcement, which is the only part that actually matters:
- A builder, not a spreadsheet. A form where
mediumis a dropdown of the closed list andcampaignis validated against the pattern. A spreadsheet of approved values is documentation; a dropdown is governance. Every organisation that relies on the spreadsheet has a report full ofEmail,emailandemial. - One owner. Somebody, by name, who adds new
mediumvalues and is allowed to say no. - A quarterly audit that lists distinct values.
SELECT DISTINCT utm_medium, count(*) FROM click_events GROUP BY 1 ORDER BY 2 DESCtakes ten seconds and tells you immediately whether the convention is real or aspirational. The long tail at the bottom of that list is your entire problem, made visible.
Nothing here is clever. UTM hygiene is not a technical problem; it is an interface problem wearing a technical costume. Give people a form that can only emit valid links, forward the parameters faithfully, and the reports look after themselves.