Skip to main content
OctoURL logoOctoURL

The UTM Builder Bug That Silently Kills Your Campaign Data

By Piero Nanni, Founder · August 5, 2026 · 7 min read

We build a UTM builder. We shipped it with a bug that silently corrupted a large share of the URLs people put through it, and we did not notice for months, because nothing about a broken tagged URL looks broken.

The URL still resolves. The page still loads. The visitor still arrives. The only thing that fails is the reporting, and it fails quietly, weeks later, when you are looking at a campaign that appears to have driven no traffic at all.

Here is what happened, why nearly every hand-rolled UTM builder has some version of the same defect, and how to test the one you use.

The bug

Our builder collected the five UTM fields, assembled them into a query string, and stuck them on the end of the destination URL:

const query = params.toString()
return query ? `${url}?${query}` : url

That question mark is the entire bug.

It is correct exactly once: when the destination URL has no query string of its own. Paste in https://example.com/pricing and you get a clean, correctly tagged URL. Paste in anything else and the output is malformed.

| What you paste | What our builder produced | | --------------------------------- | ------------------------------------------------------- | | example.com/pricing | example.com/pricing?utm_source=newsletter | | example.com/pricing?ref=partner | example.com/pricing?ref=partner?utm_source=newsletter | | example.com/pricing#plans | example.com/pricing#plans?utm_source=newsletter | | example.com/pricing? | example.com/pricing??utm_source=newsletter |

Three of those four are wrong. And the URLs marketers actually tag are rarely the clean case: landing pages carry affiliate refs, product IDs, locale switches, A/B test flags, and anchors to a section further down the page.

Why each broken form fails

The three failure modes break in different places, which is part of why the bug survives so long.

A second question mark swallows your campaign

example.com/pricing?ref=partner?utm_source=newsletter

A URL has exactly one query string, and it starts at the first ?. Every character after that belongs to the query. The second ? is not a separator; it is just a character sitting inside a parameter value.

So the destination server does not see two parameters. It sees one:

  • ref = partner?utm_source=newsletter

There is no utm_source key at all. Your analytics is not misreading the campaign, it is not receiving one. The session gets attributed to whatever the fallback is, usually direct or referral, and the campaign you spent the budget on shows nothing.

The affiliate parameter is collateral damage in the same stroke: ref now has a garbage value, so partner attribution breaks too.

A fragment hides the tags from everyone

example.com/pricing#plans?utm_source=newsletter

Everything after # is the fragment, and the fragment is a client-side construct. Browsers never send it to the server. So server-side logs, server-side tagging, and any backend attribution see a completely untagged request.

The reason this one fools people is that they assume client-side analytics will still catch it, since the fragment is right there in the address bar. It does not. GA4 reads campaign parameters out of the query string (location.search). Here the tags are in location.hash. Different property, never inspected. Nothing is recorded.

A tagged URL with an anchor is extremely common, because linking someone to a specific section of a landing page is a normal thing to want.

A doubled separator mangles the first key

example.com/pricing??utm_source=newsletter

The query string here is ?utm_source=newsletter, so the first parameter name is not utm_source. It is ?utm_source, leading question mark included. utm_source is never set.

This shows up when someone copies a URL that ends in a bare ?, which some CMS exports and email platforms produce.

Why the bug is so hard to spot

The reason we ran this for months without catching it is worth stating plainly, because it applies to whatever tool you are using right now.

Nothing surfaces the failure at the moment it happens. The generated URL looks approximately right. It is long and full of parameters, which is what a tagged URL is supposed to look like. Unless you are reading character by character, ?ref=partner?utm_source=x reads as fine.

The link works. You click it to check, and it opens the right page. Every manual test passes, because you were testing whether the link resolves, not whether the parameters parse.

The failure is invisible until reporting. By the time you notice the campaign shows no sessions, weeks have passed and the natural conclusion is that the campaign underperformed, not that the tracking never fired. That is a much more expensive mistake than a broken link, because a broken link gets reported by the first person who clicks it.

Only some URLs break. If your last few campaigns happened to use clean destination URLs, everything worked, which builds false confidence in the tool.

Test your builder in thirty seconds

You do not need to read anyone's source. Put these four URLs through whatever builder you use, with any single UTM parameter set, and read the output.

| Test input | Correct output ends with | | ------------------------------- | ---------------------------- | | https://example.com/p | /p?utm_source=test | | https://example.com/p?ref=abc | /p?ref=abc&utm_source=test | | https://example.com/p#section | /p?utm_source=test#section | | https://example.com/p? | /p?utm_source=test |

Two things to look for. The second row must join with &, not a second ?. The third row must put the tags before the #, not after it.

If a builder fails rows two or three, do not use it for any destination URL that is not perfectly clean. That is most of them.

It is also worth running your existing tagged links through this. If you have campaigns that reported suspiciously close to zero, pull the actual URLs and look for a second ?.

The fix

The rule is short. Split the fragment off first, choose the separator based on what the base URL already has, then reattach the fragment at the end:

function appendQuery(rawUrl: string, query: string): string {
    if (query === '') {
        return rawUrl
    }

    const hashIndex = rawUrl.indexOf('#')
    const base = hashIndex === -1 ? rawUrl : rawUrl.slice(0, hashIndex)
    const fragment = hashIndex === -1 ? '' : rawUrl.slice(hashIndex)

    let separator = '?'

    if (base.includes('?')) {
        // A URL left hanging on `?` or `&` already has its separator.
        separator = /[?&]$/.test(base) ? '' : '&'
    }

    return base + separator + query + fragment
}

One deliberate choice: this does not parse the URL with new URL(). That would be the textbook approach, and it handles all of the above correctly, but it throws on any input without a scheme. People paste example.com/page constantly. A builder that rejects that input is worse than one that handles it, so the string handling stays explicit.

Two smaller things we fixed at the same time:

  • Blank parameters are dropped. An empty field used to emit utm_term=, which does nothing except add noise to reports.
  • Values are properly encoded. A campaign name like spring sale&more contains a separator. Unencoded, &more becomes a parameter of its own and the campaign name is truncated at spring sale.

The current builder is live on our UTM builder page, and the behaviour above is covered by tests, so it cannot silently regress.

The part worth generalising

Tagged URLs are string concatenation, and string concatenation is where tools get lazy. A UTM builder is a small enough piece of software that everybody assumes it is correct, which is exactly why so many of them are not. The same applies to spreadsheet templates, internal scripts, and the snippet someone on the team wrote two years ago that the whole marketing department now depends on.

If your campaign data has ever been inexplicably empty, the tool that built the URLs is worth ten minutes of suspicion before the campaign is.