DEV Community

Dan Lumatic
Dan Lumatic

Posted on Originally published at demo.lumatic-labs.com AI-assisted

URL parts explained: how to parse a URL in Python and JavaScript

TL;DR

  • A URL has up to seven parts: scheme, user info, host, port, path, query, fragment. The query runs from the first ? to the first #, and the fragment never reaches the server.
  • Python parses with urlparse + parse_qs, JavaScript with new URL + searchParams. They disagree on blank values, repeated keys, missing schemes, relative URLs and default ports (table below).
  • Four traps cause most URL bugs: the missing scheme, the encoded slash, data hidden in the fragment, and punycode hostnames. Each one has a check you can paste, and the last section turns them into one validator.

Run parse_qs("color=red&debug=&page=2") in Python and debug is gone. Run the same query through searchParams in JavaScript and debug is still there, as an empty string.

One example URL runs through every snippet below.

The seven parts of a URL

https://shop.example.com:8443/catalog/shoes?color=red&size=41&size=42&page=2#reviews
Enter fullscreen mode Exit fullscreen mode
Part In the example What it does
scheme https The protocol to speak
user info none Optional credentials before the host (user:pw@host)
host shop.example.com The machine to connect to; compare this, not the whole string
port 8443 Hidden when it is the default (80 for http, 443 for https)
path /catalog/shoes The resource on that host
query color=red&size=41&size=42&page=2 Parameters, from the first ? to the first #
fragment reviews Everything after #; the browser keeps it

Two details cause most "weird parameter" tickets. A ? inside a value does not start a new query, so it gets percent-encoded. And a reserved character such as & inside a value is encoded too: q=a%26b is one parameter with the value a&b, not two. Spaces show up as %20 or +, depending on who encoded them.

Parsing a URL in Python: urlparse and parse_qs

urlparse splits the URL, and parse_qs turns the query into a dict of lists. Both live in the standard library's urllib.parse.

from urllib.parse import urlparse, parse_qs, urlencode

url = "https://shop.example.com:8443/catalog/shoes?color=red&size=41&size=42&page=2#reviews"
p = urlparse(url)

p.scheme    # 'https'
p.netloc    # 'shop.example.com:8443' (keeps user:pw@ if present)
p.hostname  # 'shop.example.com' (lowercased, port removed)
p.port      # 8443 (an int, or None)
p.path      # '/catalog/shoes'
p.query     # 'color=red&size=41&size=42&page=2' (still encoded)
p.fragment  # 'reviews' (no '#')
Enter fullscreen mode Exit fullscreen mode

Compare or connect with hostname and port, because netloc can carry user:pw@.

q = parse_qs(p.query)
# {'color': ['red'], 'size': ['41', '42'], 'page': ['2']}

q["size"]         # ['41', '42']
q.get("sort")     # None  (q["sort"] would raise KeyError)

urlencode({"q": "red shoes"})   # 'q=red+shoes' (spaces become +, like most forms send)
Enter fullscreen mode Exit fullscreen mode

Three behaviors bite everyone once:

  1. Blank values are dropped. parse_qs("color=red&debug=&page=2") loses debug unless you pass keep_blank_values=True, which brings it back as [''].
  2. Every value is a list. Repeated keys fold together, and a key that appears once still gets a one-item list.
  3. urlparse does not decode. p.query is still encoded, and parse_qs is the step that decodes (parse_qs("q=a%26b") gives {'q': ['a&b']}). Don't parse an already-decoded string, and don't decode twice.

Parsing a URL in JavaScript: URL and searchParams

The URL class is built into every browser and into Node, and searchParams is the friendlier way to read a query. The details are in the MDN URL docs.

const url = "https://shop.example.com:8443/catalog/shoes?color=red&size=41&size=42&page=2#reviews";
const u = new URL(url);

u.protocol  // 'https:'  (keeps the colon)
u.hostname  // 'shop.example.com'
u.port      // '8443'    ('' when it is the default port)
u.pathname  // '/catalog/shoes'
u.search    // '?color=red&size=41&size=42&page=2'  (keeps the '?')
u.hash      // '#reviews'  (keeps the '#')
Enter fullscreen mode Exit fullscreen mode
u.searchParams.get("color")     // 'red'
u.searchParams.get("size")      // '41'  (first value only)
u.searchParams.getAll("size")   // ['41', '42']
u.searchParams.get("sort")      // null
new URL("https://shop.example.com/?debug=").searchParams.get("debug")   // ''  (kept)
Enter fullscreen mode Exit fullscreen mode

searchParams decodes values as it reads them.

Where Python and JavaScript disagree

Each row was run on Python 3 and Node 22 or newer. Code ported across changes behavior at each one, and most without an error.

Input Python JavaScript
?debug= (blank value) parse_qs drops it unless keep_blank_values=True searchParams.get("debug") returns ""
size=41&size=42 parse_qs returns {'size': ['41', '42']}, always lists get returns '41', getAll returns both
shop.example.com/catalog (no scheme) urlparse accepts it, netloc is '', everything lands in path new URL() throws
/a?b=1 (relative URL) urlparse parses it: path /a, query b=1 new URL() throws unless you pass a base: new URL("/a?b=1", "https://shop.example.com")
No port in the URL p.port is None u.port is ''

Four traps that cause most URL bugs

1. The missing scheme. Browsers hide the scheme, so people paste addresses without one. Strip https:// from the example and Python accepts the string quietly:

p = urlparse("shop.example.com/catalog/shoes?color=red&size=41&size=42&page=2#reviews")
p.netloc   # ''  (empty)
p.path     # 'shop.example.com/catalog/shoes'  (everything lands in path)
Enter fullscreen mode Exit fullscreen mode

JavaScript throws on the same input. Python's version is the harder one to catch, because the bad value travels on until something further down breaks. The check goes where the URL enters your code (a ready-made one is in the last section).

2. The encoded slash. A callback URL can pass in staging and 404 in production with identical code, because some servers decode %2F early and others reject it outright. Both parsers hand back /files/a%2Fb untouched, so the difference sits in whatever server or proxy handles the request first. Some APIs expect double-encoded slashes (%252F), so read the docs of the one you call. Test each environment with the same two requests:

curl -s -o /dev/null -w '%{http_code}\n' 'https://your-host/files/a%2Fb'
curl -s -o /dev/null -w '%{http_code}\n' 'https://your-host/files/a%252Fb'
Enter fullscreen mode Exit fullscreen mode

Different status codes in staging and production mean the stack in front of your app handles slashes differently.

3. Data in the fragment. The browser strips everything after # before the request leaves the machine. Picture a campaign link that carries its tracking token after the #: the analytics dashboard shows zero attributed visits, and the server log has nothing to explain it. curl strips the fragment the same way, so you can reproduce it:

curl -s -o /dev/null 'https://your-host/page?a=1#token=abc'
Enter fullscreen mode Exit fullscreen mode

Grep your access log. You will find /page?a=1 and no #token=abc. Anything the server needs belongs in the path or the query.

4. Punycode hostnames. Internationalized domains travel as punycode, so münchen.example and xn--mnchen-3ya.example are the same host, and both forms show up in logs and redirects. Say your redirect allowlist was copied from a log and holds xn--mnchen-3ya.example. Your JavaScript front end sees that form, but Python reports the Unicode one, so a valid redirect gets rejected:

urlparse("https://münchen.example").hostname    # 'münchen.example'  (Unicode kept)
"münchen.example" == "xn--mnchen-3ya.example"   # False
"münchen.example".encode("idna")                # b'xn--mnchen-3ya.example'
Enter fullscreen mode Exit fullscreen mode
new URL("https://münchen.example").hostname     // 'xn--mnchen-3ya.example'  (converted)
Enter fullscreen mode Exit fullscreen mode

Convert both sides with IDNA before you compare.

Validate once, at the boundary

The ?debug= flag from the top disappears in Python because parse_qs drops blank values by default. Don't patch the four traps one by one where they surface. Put one function where URLs enter your code and parse everything through it:

from urllib.parse import urlparse, parse_qs

def parse_url_strict(raw):
    p = urlparse(raw)
    if p.scheme not in ("http", "https") or not p.hostname:
        raise ValueError("expected an absolute http(s) URL")
    host = p.hostname.encode("idna").decode()
    return p, host, parse_qs(p.query, keep_blank_values=True)
Enter fullscreen mode Exit fullscreen mode

Then use the five inputs from the disagreement table as its test set. ?debug= must come back as {"debug": [""]}, the repeated size must give two values, a URL with no port must give port as None, and both shop.example.com/catalog and /a?b=1 must raise. Add a münchen.example case that returns the punycode host.

Try this: paste a real callback URL from your app into parse_url_strict, then remove the scheme and run it again. The second run should raise.

Top comments (0)