Skip to content

URL Normalization

seoslug normalizes URLs through a deterministic pipeline. The output is always a clean canonical URL.

Every step is controlled by URLPolicy and canonical_host.

The pipeline

URLs pass through these steps in order.

0. Base path prepending

If public_base_url contains a path, it is prepended to the route path before any other step.

from seoslug import SEOConfig

config = SEOConfig(
    canonical_host="example.com",
    public_base_url="https://example.com/blog",
    url_policy=...,
)
Input Result
/page /blog/page
/ /blog

This enables sub-path deployments like https://example.com/blog/.

1. Scheme enforcement

Sets the URL scheme. HTTPS by default.

enforce_https Behavior
True Always https
False Uses scheme from public_base_url
from seoslug import URLPolicy

URLPolicy(enforce_https=True)

http://other.com/page becomes https://portal.example.com/page.

2. Host enforcement

Always replaces the host with canonical_host. Host injection attacks are prevented.

SEOConfig(canonical_host="portal.example.com")

https://evil.com/path becomes https://portal.example.com/path.

3. Path normalization

Two operations controlled by URLPolicy:

Setting Default Effect
lowercase_paths True Lowercases the path
collapse_duplicate_slashes True Reduces // to /
URLPolicy(
    lowercase_paths=True,
    collapse_duplicate_slashes=True,
)

//Blog//My-Post// becomes /blog/my-post.

4. Trailing slash

Three modes:

trailing_slash Effect
"never" Removes trailing slash
"always" Adds trailing slash
"preserve" Leaves as-is
URLPolicy(trailing_slash="never")

/blog/post/ becomes /blog/post.

5. Query parameter filtering

Two layers of filtering:

Tracking param stripping: removes UTM parameters, fbclid, gclid, msclkid, and 60+ more. Uses the detrack library if installed, otherwise a built-in regex.

strip_tracking_params Behavior
True (default) Strips known tracking params
False Keeps all params

Query allowlist: when set, only the listed params are kept. All others are removed.

URLPolicy(
    strip_tracking_params=True,
    allowed_query_params=["q", "page", "sort"],
)

?utm_source=twitter&q=python&bad=1&page=2 becomes ?q=python&page=2.

Sub-path deployment example

from seoslug import SEOConfig, URLPolicy

config = SEOConfig(
    canonical_host="example.com",
    public_base_url="https://example.com/blog",
    url_policy=URLPolicy(
        lowercase_paths=True,
        trailing_slash="never",
        collapse_duplicate_slashes=True,
        strip_tracking_params=True,
    ),
)

# Given route "/about"
# Result: "https://example.com/blog/about"

# Given route "/"
# Result: "https://example.com/blog"

Sub-path canonical URLs work because base path prepending happens at step 0, before path normalization.

Standalone use

URL normalization functions are public. Use them without building a full payload.

from seoslug import normalize_public_url, normalize_path, URLPolicy

# Normalize a full URL with config
url = normalize_public_url(
    "http://evil.com//Blog/Post?utm_source=x",
    config,
)
# Result: "https://example.com/blog/post"

# Normalize just the path with a policy
path = normalize_path(
    "//Blog//My-Post//",
    URLPolicy(lowercase_paths=True, trailing_slash="never"),
)
# Result: "/blog/my-post"

Idempotency

Normalization is idempotent. Running it twice produces the same result.

url = "http://example.com//A//B/?utm_campaign=x"
first = normalize_public_url(url, config)
second = normalize_public_url(first, config)
assert first == second  # always True

This holds for sub-path deployments too.