URL Normalization Explained: When Two URLs Are Really the Same Page
How URL normalization works — lowercase host, drop default port, sort query params, decode escapes — and why it matters for dedup, canonical tags, and caching.
URL Normalization Explained: When Two URLs Are Really the Same Page
Here are five strings. How many distinct web pages do they point to?
HTTPS://Example.com:443/Blog/?b=2&a=1
https://example.com/Blog?a=1&b=2
https://example.com/Blog/?utm_source=newsletter&a=1&b=2
https://example.com/Blog/#section
https://example.com/Blog%2F?a=1&b=2
A human glances at them and shrugs. A reverse-proxy cache, a SQL GROUP BY, and a Set<string> all treat them as five different things. That gap — between "looks the same to me" and "is the same byte-for-byte to a machine" — is the entire problem URL normalization solves. Normalization rewrites every URL into one canonical form so links that resolve to the same page collapse into a single key.
What a URL is actually made of
A URL has parts, and the rules for "are these equal" differ per part. That asymmetry is why naive string comparison fails.
- Scheme (
https) — case-insensitive.HTTPSequalshttps. - Host (
Example.com) — case-insensitive. DNS does not care about case, soExample.com,EXAMPLE.COM, andexample.comare one host. - Port (
:443) —:443is the default for HTTPS and:80for HTTP. Including the default port changes nothing about where the request goes. - Path (
/Blog/) — case-sensitive on most servers./Blogand/blogmay be two genuinely different resources, so a normalizer must not touch path case. - Query (
?a=1&b=2) — order usually does not matter to the server, but?a=1&b=2and?b=2&a=1are different strings. - Fragment (
#section) — never sent to the server. It is a client-side anchor, irrelevant to which page loads.
Once you internalize that each component has its own equality rule, normalization stops being mysterious. You apply the right transform to the right part and leave the rest alone.
The normalization rules that matter
These are the transforms that turn messy input into a stable key, and the ones I reach for first:
- Lowercase the scheme and host, leave the path alone.
HTTPS://Example.com/Blogbecomeshttps://example.com/Blog. The path keeps its capitals because/Blogand/blogcan be different files. - Drop the default port.
https://example.com:443/becomeshttps://example.com/. Keep:8080— that one is meaningful. - Resolve the trailing slash consistently.
/blogand/blog/are technically distinct paths, but most sites serve them identically. Pick one rule (usually add a trailing slash to directory-style paths) and apply it everywhere so you do not split traffic across two keys. - Sort the query parameters.
?b=2&a=1becomes?a=1&b=2. The server reads the same values either way, so sorting makes the string deterministic. - Decode unnecessary percent-escapes.
%2Dis just-, and%7Eis~. Unreserved characters do not need escaping, so decoding them shrinks two spellings of the same URL into one. Reserved characters like%2F(/) stay encoded, because decoding those would change the path structure. - Strip tracking noise.
utm_source,utm_medium,gclid, andfbclidchange the string without changing the page. Removing them is how three campaign variants of one article finally line up.
Each rule is small. Applied together, they collapse the five strings from the top of this post into one.
A worked example
Take the worst URL from the opener and walk it through:
Input: HTTPS://Example.com:443/Blog/?utm_source=newsletter&b=2&a=1#intro
- Lowercase scheme and host →
https://example.com:443/Blog/?utm_source=newsletter&b=2&a=1#intro - Drop the default
:443→https://example.com/Blog/?utm_source=newsletter&b=2&a=1#intro - Strip the fragment (
#intronever reaches the server) →https://example.com/Blog/?utm_source=newsletter&b=2&a=1 - Remove tracking params (
utm_source) →https://example.com/Blog/?b=2&a=1 - Sort the query →
https://example.com/Blog/?a=1&b=2
Output: https://example.com/Blog/?a=1&b=2
Notice what stayed: /Blog/ kept its capital B, and the two real parameters a and b survived because they carry meaning. Notice what left: case noise on the host, a redundant port, a client-only fragment, and a marketing tag. Run a list of fifty links through that same pipeline and the duplicates fall out on their own.
Why this matters in practice
Three jobs depend on it, and they fail quietly when you skip it.
Deduplicating link lists. You export click data from analytics, paste in a support ticket's pasted URLs, and merge a CSV from a partner. The same article shows up six ways. Without normalization, Set dedup keeps all six; with it, you get one row. This is the everyday case I hit most, and a URL Normalizer handles it without sending your link columns to a server — paste, normalize, sort, then run the result through a URL deduplicator for the final unique list.
Canonical URLs for SEO. Google needs to know which of your near-identical URLs is the real one. If example.com/blog, example.com/blog/, and example.com/blog?utm_source=x all serve the same content with no rel=canonical, your ranking signal splits across three URLs. Normalizing to a single canonical form — then emitting that exact string in your <link rel="canonical"> — keeps the authority pooled on one page.
Cache keys. A CDN or in-memory cache keys on the URL string. ?a=1&b=2 and ?b=2&a=1 produce two cache entries for one response: double the storage, half the hit rate. Sorting query params before they become a cache key is a one-line fix that quietly improves your hit ratio.
The first time it bit me
I once spent an afternoon debugging why a "unique visitors per article" number was three times too high. The query grouped on the raw landing-page URL. Marketing had been appending utm_campaign to every shared link, so one blog post had spawned a dozen distinct URL strings, each counted as its own page. The data was not wrong — the URLs genuinely differed as strings. They just all pointed to the same article. I added a normalization step that lowercased the host, dropped the fragment, stripped utm_*, and sorted the rest, and the count snapped back to reality. The fix was not clever. It was just applying the equality rules each URL component already follows.
A few traps to watch
- Do not lowercase the path. It is the single most common mistake, and on a case-sensitive server it merges two real pages into one.
- Do not decode reserved characters.
%2Fis an encoded slash inside a path segment; turning it into/rewrites the route. Only decode unreserved characters. - Treat the fragment carefully. For dedup and cache keys, drop it. For an in-page anchor you genuinely want to preserve, keep it. Know which job you are doing.
- Validation is not existence. A URL can be perfectly well-formed and point at nothing. Normalizing a link tells you it is shaped correctly, not that the page is live. If you are also checking format, hand the cleaned list to a URL list validator and read its per-row reasons.
Normalization is not glamorous, but it is the difference between a link list you can trust and one that lies to you. Apply the right rule to the right component, and two URLs that are the same page finally look the same to your code.
Made by Toolora · Updated 2026-06-13