For links from other sites, ads and emails only. A UTM on a link between your own pages starts a new session with the wrong source and wipes out the real attribution.
Tag a link with UTM parameters so the visit shows
up in GA4 as "newsletter / email" instead of landing in the Direct
row. The builder keeps existing query strings and
#fragments, and lowercases values so Facebook and
facebook do not become two sources. Bulk mode tags a whole list of
links with the same parameters.
For links from other sites, ads and emails only. A UTM on a link between your own pages starts a new session with the wrong source and wipes out the real attribution.
The UTM parameters go after the existing color=red and before the fragment. Keep UTM tags in front of the #. The browser never sends the fragment to the server, and GA4 does not read tags placed there.
URL https://example.com/shoes?color=red#reviews
utm_source Newsletter
utm_medium email
utm_campaign Spring Sale 2026
utm_content hero button https://example.com/shoes?color=red&utm_source=newsletter&utm_medium=email&utm_campaign=spring-sale-2026&utm_content=hero-button#reviews Who sent the visit and what kind of link it was. GA4 sorts traffic into channels (Paid Search, Email, Organic Social) from these two, so the spelling matters. For example, email as a medium lands in Email, and newsletter as a medium does not.
The campaign name people read in reports, and an optional ID for joining with cost data you import. Pick a naming scheme once and keep it, because GA4 treats spring-sale and Spring_Sale as two campaigns.
The paid keyword. Google Ads supplies keyword data through auto-tagging (gclid), so set utm_term for other ad platforms.
Separates two links in the same campaign, such as the header button and the footer link in one email, or ad variant A and B.
Never tag internal links. Say a homepage banner points at your own sale page with UTM tags. Every click on it replaces the visitor's traffic source with that banner, so the ad or email that brought the visitor in loses the credit for what happens next. For on-site clicks, use an event instead.
Test the link through every redirect. A shortener or a server redirect that drops the query string removes the tags without any error, and the visit lands in Direct. Click the finished link once and check the address bar before you send it to 40,000 subscribers.
function addUtm(link, tags) {
const url = new URL(link);
for (const [key, value] of Object.entries(tags)) {
if (value) url.searchParams.set("utm_" + key, value.trim().toLowerCase());
}
return url.toString(); // the #fragment stays at the end
}
console.log(addUtm("https://example.com/shoes?color=red#reviews", {
source: "newsletter",
medium: "email",
campaign: "spring sale 2026",
}));
// https://example.com/shoes?color=red&utm_source=newsletter
// &utm_medium=email&utm_campaign=spring+sale+2026#reviews searchParams rewrites the whole query in form encoding, so a space becomes + and an existing %20 in another parameter becomes + as well. Both decode to a space. If you need the original bytes untouched, append to the string instead.
<?php
function add_utm(string $link, array $tags): string {
$parts = parse_url($link);
parse_str($parts['query'] ?? '', $query);
foreach ($tags as $key => $value) {
$query['utm_' . $key] = strtolower(trim($value));
}
return $parts['scheme'] . '://' . $parts['host']
. (isset($parts['port']) ? ':' . $parts['port'] : '')
. ($parts['path'] ?? '/')
. '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986)
. (isset($parts['fragment']) ? '#' . $parts['fragment'] : '');
}
echo add_utm('https://example.com/shoes?color=red#reviews', [
'source' => 'newsletter', 'medium' => 'email', 'campaign' => 'spring sale',
]); parse_str turns dots and spaces in parameter names into underscores, so a query like a.b=1 comes back as a_b=1. This is rare, but PHP makes the change without a warning.
from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode, quote
def add_utm(link, **tags):
parts = urlsplit(link)
query = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)
if not k.startswith("utm_")]
query += [("utm_" + k, v.strip().lower()) for k, v in tags.items() if v]
return urlunsplit(parts._replace(query=urlencode(query, quote_via=quote)))
print(add_utm("https://example.com/shoes?color=red#reviews",
source="newsletter", medium="email", campaign="spring sale"))
# https://example.com/shoes?color=red&utm_source=newsletter&utm_medium=email&utm_campaign=spring%20sale#reviews UTM parameters come from Urchin, the analytics company Google bought in 2005, which is where the U comes from. They are ordinary query parameters: the server ignores them and the analytics script reads them from the landing page URL. This is why they have to survive every redirect on the way in. They also appear in any URL a visitor copies and shares, so keep anything private out of them.
Google Ads adds a gclid parameter automatically when auto-tagging is on, and GA4 prefers it to manual UTMs for Google Ads traffic. Tag Google Ads links by hand only if auto-tagging is off or you also send the traffic to a tool that does not read gclid.
This tool follows the specifications below. Read them when you need the exact normative wording, for example to settle an edge case between two implementations.
Always set utm_source, which identifies who sent the visit. Google also recommends utm_medium and utm_campaign, plus utm_id if you import cost data. Any that are missing show up as (not set) in GA4 reports.
Yes. GA4 is case-sensitive, so Facebook, facebook and FACEBOOK become three separate sources. The Lowercase option is on by default for that reason.
No. A UTM on a link between your own pages replaces the visitor's traffic source with that link, so the ad, email or search that brought the visitor in loses the credit. Track on-site clicks with events instead.
Use a value GA4's default channel rules recognize, such as paid_social, or cpc with a social source like facebook or linkedin. A medium like fb-ads matches no rule, and the traffic may be filed as Unassigned.
No. The browser never sends the fragment to the server, and GA4 reads the query string, so the tags must come before the #. The builder inserts them before any fragment and keeps your existing query parameters.
To see what a tagged link already carries, paste it into the query string parser or the URL parser. For encoding a single value by hand, use the URL encoder.