Lesson 38 · Practical · 18 min

Python URL parsing

Resolve relative links correctly before they enter the crawl queue.

urlparseurljoinparse_qsurlencode

Every product link on the supplier page is relative, and half carry tracking parameters. Treated as plain strings, they become duplicates. What does urljoin know that concatenation does not?

Scraped pages are full of relative links, tracking parameters, and fragments. Treating them as plain strings creates duplicates and broken requests. urllib.parse gives you a small, standard toolkit for splitting, joining, and rebuilding URLs. You will also normalize URLs for deduplication and reject unsafe schemes or hosts.

Split a URL into components

urlparse separates the scheme, network location, path, query, and fragment. The fragment is for the browser and is not sent to the server. Reading components separately is safer than slicing the string at the first slash.

from urllib.parse import urlparse

url = urlparse("https://example.com/docs/page.html?q=python#intro")
print(url.scheme)
print(url.netloc)
print(url.path)
print(url.query)

Five parts, one object

Resolve relative links against the page

urljoin resolves a relative reference against the page URL. It handles ../, root-relative paths, and query-only links. Always join before deduplicating; /docs and https://example.com/docs are the same destination but different strings.

from urllib.parse import urljoin

base = "https://example.com/docs/page.html"
print(urljoin(base, "../guide.html"))
print(urljoin(base, "/about"))
print(urljoin(base, "?page=2"))

One base, three kinds of link

Read query parameters

parse_qs turns a query string into a dictionary of lists because a key can appear more than once. Use parse_qsl when order matters. Values are still strings, so convert page numbers explicitly before using them in arithmetic.

from urllib.parse import parse_qs

params = parse_qs("q=python&page=2&tag=a&tag=b")
print(params["page"])
print(params["tag"])

Repeated keys become lists

Build a query string safely

urlencode escapes spaces, ampersands, and non-ASCII text for you. Pass a mapping or a sequence of pairs. Do not concatenate raw user input into a URL; one stray ampersand can change the meaning of the request.

from urllib.parse import urlencode

query = urlencode({"q": "a b", "page": 2})
print(query)

Encode instead of concatenating

Normalize before deduplication

The same page can be written with a different case, default port, fragment, or query order. Normalize scheme and host to lowercase, remove the default port, sort the query, and drop the fragment before comparing URLs. Keep the path case-sensitive.

from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit

def normalize(url):
    parts = urlsplit(url)
    scheme = parts.scheme.lower()
    host = (parts.hostname or "").lower()
    default_port = 443 if scheme == "https" else 80
    port = parts.port
    netloc = host if not port or port == default_port else f"{host}:{port}"
    query = urlencode(sorted(parse_qsl(parts.query, keep_blank_values=True)))
    return urlunsplit((scheme, netloc, parts.path or "/", query, ""))

for raw in [
    "HTTPS://Example.com:443/docs?b=2&a=1#top",
    "https://example.com/docs?a=1&b=2",
]:
    print(normalize(raw))

Two spellings, one key

Reject unsafe schemes and unexpected hosts

urljoin follows whatever scheme the link provides, including javascript: and data:. Parse the result and allow only http or https plus an expected host before fetching it. A relative link can still become an external URL after joining, so check after resolution as well as before.

from urllib.parse import urljoin, urlsplit

base = "https://example.com/docs/page"
for link in ["/next", "javascript:alert(1)", "https://evil.example/x", "//example.com/other"]:
    target = urljoin(base, link)
    parts = urlsplit(target)
    safe = parts.scheme in {"http", "https"} and parts.hostname == "example.com"
    print(link, safe, target)

Resolve first, then allowlist

Key points

  • urlparse exposes scheme, netloc, path, query, and fragment separately.
  • urljoin resolves relative links against the page that contained them.
  • parse_qs returns lists because query keys may repeat.
  • urlencode escapes values before they enter a URL.
  • Remove fragments before deduplicating crawl queues.
  • Normalize before comparing; check scheme and host after urljoin.

Common mistakes

  • Joining relative links with string concatenation.
  • Assuming a query parameter appears only once.
  • Keeping the #fragment when comparing two page URLs.
  • Comparing raw URL strings and fetching a javascript: link that slipped through.
Your turn

Resolve three relative links against a base URL and print the unique absolute URLs in first-seen order.

Try it yourself

Change the values, predict the output, then run the code. The Python runtime is downloaded the first time you press Run, so the first run can take a few seconds.

In-browser PythonRuns locally · no account
First run downloads Pyodide from a CDN. Use Run code after editing.

Practice this lesson

12 questions with immediate explanations.

See all 12 answersStart practice
← HTML parsingPagination →