Uppercase URLs and the Duplicates They Create

In a URL, the scheme and the host are case-insensitive. The path is not. example.com/About and example.com/about are two different URLs, and whether they return the same content depends entirely on your server and filesystem. On a case-sensitive filesystem one of them is a 404; on a case-insensitive one, both serve the same page with a 200, and you have a duplicate.

The second case is the problem, because it is silent. Everything works, and you have quietly doubled — or more than doubled — the number of addresses at which each page can be indexed.

Where the mixed-case URLs come from

Case-insensitive filesystems and servers. Windows and macOS default to case-insensitive filesystems, and IIS is case-insensitive by default. A site developed or hosted in those environments serves every path at every capitalisation. /About, /ABOUT, /aBoUt — all 200.

Legacy URL schemes. Older CMS platforms and hand-built sites frequently used title-case paths: /Products/Widgets.html. After a migration to lowercase URLs, both forms may still resolve.

Campaign and referral URLs. A parameter capitalised inconsistently — ?Ref=Newsletter versus ?ref=newsletter — produces distinct URLs, and query-parameter keys and values are both case-sensitive. Related surface: query parameters that multiply your URL count.

Human transcription. Someone types a URL from print, from a slide, or from memory and capitalises the first letter. That link now exists on the web pointing at a variant.

Email clients and link shorteners occasionally case-mangle URLs, though this is rarer than it used to be.

What the duplication actually costs

Two indexed URLs serving identical content means the engine has to pick one, and it will — a canonical is inferred even when none is declared. What you lose is control over which one, plus whatever splitting occurs while the engine works it out.

More concretely: external links arriving at /About and internal links pointing at /about are pointing at two different addresses, and consolidating them is the engine’s judgement rather than your declaration. That is the situation described in canonical tags are a hint, not a command — you can express a preference, and you had better make sure nothing contradicts it.

The reporting cost is real too. Analytics and log analysis group by URL string, so a page’s traffic is split across capitalisation variants and every report understates it.

Deciding the canonical form

Lowercase. There is no serious argument for anything else: it is the convention, it is unambiguous, it is what people type, and it avoids the entire class of question about which letter gets capitalised.

The only nuance is where to apply it. Lowercase the path; leave the query string alone unless you know your application does not care. Parameter values are frequently case-significant — an ID, a base64 token, a signed hash — and lowercasing them breaks the request in a way that is much worse than a duplicate URL.

Enforcing it

nginx has no built-in case conversion in the core rewrite module, so the practical approach is either to handle it in the application layer or to enumerate the known-bad forms. If you have the Perl module available:

# nginx (with ngx_http_perl_module): lowercase the path, preserve the query
perl_set $lc_uri 'sub { return lc(shift->uri); }';

if ($request_uri ~ [A-Z]) {
    return 301 https://example.com$lc_uri$is_args$args;
}

Note the condition tests $request_uri but the redirect target is built from the lowercased path plus the untouched query — so a capital letter anywhere triggers the rule, and only the path is changed. That is deliberate, and it means a URL with a capital only in its query string will redirect to an identical URL, which is a loop. Test that case specifically, and narrow the condition to $uri ~ [A-Z] if your query strings can contain capitals.

Apache, which has the tooling for this:

# Apache: lowercase the path only, skipping real files
RewriteEngine On
RewriteMap lc int:tolower
RewriteCond %{REQUEST_URI} [A-Z]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^/?(.*)$ /${lc:$1} [R=301,L]

RewriteMap requires server or virtual-host configuration — it cannot go in .htaccess, which is the usual reason this approach gets abandoned halfway.

The !-f condition is not optional. Without it, a request for /images/Logo.png redirects to /images/logo.png, which on a case-sensitive filesystem does not exist. You have converted a working asset into a 404 via a 301, which is harder to debug than either problem alone.

The safer alternative

If you cannot get a normalisation rule in without risk, the fallback is a self-referencing canonical tag emitting the lowercase form on every page. It does not stop the duplicate URLs being served, and it is a hint rather than a rule, but it costs nothing and it is consistent with everything else you emit.

Combine it with fixing your own links, which is the part you fully control:

# find internal links containing uppercase path characters
grep -rhoE 'href="/[^"?#]*[A-Z][^"]*"' src/ | sort -u

Any hit is a link you are emitting at a non-canonical address, and it is contradicting the canonical you just declared. The internal graph is the one part you can simply make correct.

Combining with the other normalisations

Case is the third of the three normalisations that every site needs, alongside protocol/host and trailing slash. Applied as three independent rules they compose into a chain:

http://example.com/About  → 301 → https://example.com/About
https://example.com/About → 301 → https://example.com/about
https://example.com/about → 301 → https://example.com/about/

Three hops, all avoidable. The fix is a single rule that evaluates all three properties and emits one redirect to the fully normalised URL — the pattern in consolidating www and HTTPS in a single hop, extended with the case check. Slash handling specifically is in trailing slashes are different URLs.

Verifying

for u in /about /About /ABOUT; do
  printf '%s -> ' "$u"
  curl -sI "https://example.com$u" \
    | awk 'NR==1{c=$2} /^[Ll]ocation:/{l=$2} END{print c, l}'
done

The output you want is one 200 for /about and a single 301 to it from each variant. Anything returning 200 for more than one form is serving duplicates today, whether or not they have been indexed yet — and the ones that have been indexed are visible in a site: query or in the coverage report’s duplicate categories.