LESSON 76 · DEEPER PYTHON29 min

Build Query Strings with urllib.parse

Replace one URL query parameter without losing existing parameters or the fragment.

Before you start: Basic String Operations · Lists · Functions

Example check: Worked and guided code samples are checked against CPython 3.13. Practice uses Pyodide 0.29.5 (Python 3.13) in your browser. How we check lessons

Video lesson: Build Query Strings with urllib.parse

The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube

Read the video transcript

Select a timestamp to watch that moment on YouTube.

  1. 00:00

    Imagine that a learner has a search page URL and wants to replace the search phrase with red fox. The tempting solution is string concatenation: add a question mark and the new phrase. But this URL already has a query, already contains another parameter, and already has a fragment. We need to preserve all of those pieces. Today we will run the naïve approach and inspect exactly what goes wrong. Then we will split the URL into components, treat its query as an ordered list of pairs, replace one key, and put the URL back together. This lesson does not send a request or contact the example domain.

  2. 00:40

    It is purely about constructing a predictable URL string. At the end we will test a blank parameter, two occurrences of an unrelated key, a fragment, and a Unicode search term. Those tests matter because a function that works only for a neat one-parameter example is very easy to write and very easy to trust too early. First, predict what happens if we add a second question mark to a URL that already has one. The first line may look like a URL with two searches, but the parser tells a more useful story. The query is still q equals old and lang equals en. Everything after the hash is part of the fragment, including the extra question

  3. 01:21

    mark and red fox. A fragment is not part of the server query, so this operation did not update the search value at all. It also left a raw space in the text, which should be encoded in a query value. The important diagnosis is structural: a URL is not one undifferentiated string. The order of query and fragment markers changes the meaning of the same characters. Adding another question mark is not a general update operation, and adding text at the end is even less reliable when a fragment is present. We need a way to access the existing query without disturbing the path or fragment.

  4. 02:02

    Python's urllib.parse supplies urlsplit for this job. It does not decide whether a URL is trusted, but it gives us the components needed for a deterministic transformation. Let us inspect those components before trying the repair. urlsplit gives us a path, a query string, and a fragment as separate fields. parse_qsl converts the query into a list of key and value pairs. Notice that tag appears twice. That is legal query data and may be intentional, for example when a filter accepts multiple tags. If we immediately convert this list to a dictionary, one tag value disappears.

  5. 02:42

    The blank empty parameter also survives because we explicitly requested keep_blank_values. Without that option, a blank query value can be dropped during parsing. So our replacement policy should be precise: remove every pair whose key is the target, keep the other pairs in order, and append one new target pair. That handles repeated old q values without deciding that unrelated duplicates are errors. We also need to reassemble the original scheme, host, path, and fragment after the edit. When the pairs become a query again, urlencode will encode the values. This is where a space, ampersand, or accented character should be handled;

  6. 03:24

    manually concatenating those characters would be error-prone. Now let us implement the function and look at the exact result. We have one query marker, the unrelated language parameter remains, and the fragment is still at the end. The old q value is gone, replaced with a single q equals red plus fox. In this query encoding, plus represents the space between the words. Read through the function from top to bottom. urlsplit names the components. parse_qsl gives us pairs and retains blanks. The list comprehension removes every pair with the exact requested key, while leaving all other pairs in their previous order. append adds the new value once.

  7. 04:08

    urlencode turns the pair list into valid query syntax, and urlunsplit reconstructs the URL with the original nonquery components. This function has a useful, narrow contract. It preserves decoded parameter values and sequence, but it may normalize the spelling of existing percent escapes when it decodes and re-encodes the original query. If an application needs the original query bytes preserved exactly, that is a different requirement and needs a different editor. For a normal decoded query transformation, this sequence is easy to test and review. Let us now challenge it with awkward inputs.

  8. 04:48

    The first result keeps both tag values, keeps the empty value, replaces q, and leaves the fragment after the query. The second shows that a Unicode accent becomes UTF-8 percent encoding, the space becomes plus, and the ampersand inside the value becomes percent two six rather than acting as a separator between parameters. If one of these results surprises you, inspect the pair list before urlencode. That tells you whether the bug came from your filtering rule or from your expectations about encoding. One final boundary is important: a parsed URL is not automatically safe to request. urlsplit can identify a scheme and host without checking whether your application

  9. 05:31

    should access that destination. If you later accept a user-supplied URL for fetching, validate its scheme and destination separately and account for redirects. Our function never contacts the network; it only edits a chosen URL string. Try the checked task on the lesson page, then change one test input yourself. Predict the exact pair order before running the code. A good URL helper is more than a short function: it is a contract that survives the blank, duplicate, Unicode, and fragment cases you just watched.

Understand the concept

A URL has separate scheme, authority, path, query, and fragment components. Treating all of them as one string makes a small update surprisingly easy to break. Appending '?q=red fox' to a URL that already has a query puts a second question mark inside the query, and a fragment after # never reaches a server. urllib.parse.urlsplit exposes the components without making a network request.

A query is a sequence of key–value pairs, not necessarily a dictionary. The same key can appear more than once, and a blank value can be meaningful. parse_qsl(..., keep_blank_values=True) preserves both properties. To replace a key, filter all old pairs with that key, append the new pair, then pass the list to urlencode. urlencode escapes spaces and non-ASCII text for a query string. urlunsplit assembles the original scheme, authority, path, and fragment with the new query.

The checked task implements set_query_parameter(url, key, value). It preserves unrelated pairs in their original order, removes every previous occurrence of key, and appends one new value. Try a URL with an existing query and #results, then test a blank value, a repeated key, and a search term containing a space. Re-encoding may normalize the spelling of existing percent escapes even when their decoded meaning stays the same; this function preserves values and order, not every original byte.

URL parsing is not URL validation. urlsplit may accept an odd or unsafe address, and urlencode does not decide whether a destination is trusted. If a program navigates to a user-supplied URL, validate scheme and host separately against an explicit policy. This lesson only edits a query in an already chosen URL; it does not open the URL or send the learner's text anywhere.

  • urlsplit and urlunsplit separate and reassemble URL components
  • parse_qsl retains repeated pairs and optional blank values
  • urlencode encodes query data rather than a whole URL
  • URL parsing does not establish that a destination is trusted

See it step by step

Read the code, predict the output, then compare it with the result.

01. Inspect the parts before editing

PYTHON
from urllib.parse import urlsplit
parts = urlsplit("https://example.test/search?q=red+fox#top")
print(parts.path)
print(parts.query)
print(parts.fragment)
EXPECTED OUTPUT
/search
q=red+fox
top

The query and fragment are separate fields. A fragment belongs after the query when the URL is rebuilt.

02. Keep blank values and repeated keys

PYTHON
from urllib.parse import parse_qsl
pairs = parse_qsl("tag=a&tag=b&empty=", keep_blank_values=True)
print(pairs)
EXPECTED OUTPUT
[('tag', 'a'), ('tag', 'b'), ('empty', '')]

A list of pairs preserves both tag values and the empty value. A plain dictionary cannot represent repeated tag keys.

03. Encode query data rather than concatenating text

PYTHON
from urllib.parse import urlencode
print(urlencode([("q", "café & tea"), ("page", "2")]))
EXPECTED OUTPUT
q=caf%C3%A9+%26+tea&page=2

urlencode converts UTF-8 text and reserved characters into query syntax, including + for a space.

A closer look

Follow the reasoning, inspect each result, then try the suggested changes in the console below.

01 / 03

Inspect a URL before changing its query

Start with a URL that already contains a query and a fragment. A second question mark does not begin a second query; it becomes text inside the first one. Likewise, a fragment is a separate client-side component, so blindly appending text after the hash changes the fragment rather than the server query. urlsplit lets us name the parts before we make an edit. It does not contact the host or assert that the host is safe.

Print the path, query, and fragment independently. Think of the query as a sequence of pairs. This matters for repeated keys such as tag=a&tag=b. A dictionary would collapse them to one value, which is often not what a caller expects. Here our goal is to replace one named parameter while keeping every unrelated pair and keeping the fragment exactly in its original position.

PYTHON
from urllib.parse import urlsplit, parse_qsl

parts = urlsplit("https://example.test/search?tag=a&tag=b&empty=#top")
print(parts.path)
print(parse_qsl(parts.query, keep_blank_values=True))
print(parts.fragment)
EXPECTED OUTPUT
/search
[('tag', 'a'), ('tag', 'b'), ('empty', '')]
top
Follow the reasoning
  1. Identify each URL component before editing it.
  2. Notice that keep_blank_values=True retains empty= and the pair list retains both tag values.
02 / 03

Replace one key and reassemble the components

Filter out every old occurrence of the target key, then append the new pair. This defines what replace means when a query contains duplicates: all old target values are removed, and exactly one new value remains. Leave unrelated pairs as a list to preserve their order and repeated values. urlencode takes care of a search term containing a space, an ampersand, or non-ASCII text. Its default query encoding turns a space into a plus sign.

urlunsplit rebuilds the URL using the original scheme, netloc, path, and fragment with the freshly encoded query. One subtle limit is that parse_qsl decodes an existing query before urlencode encodes it again. The semantic pair values and ordering survive, but an existing percent escape might be spelled differently after this round trip. If byte-for-byte URL preservation is a requirement, use a more specialized editing strategy.

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

def set_query_parameter(url, key, value):
    parts = urlsplit(url)
    pairs = [(name, item) for name, item in parse_qsl(parts.query, keep_blank_values=True) if name != key]
    pairs.append((key, value))
    return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(pairs), parts.fragment))

print(set_query_parameter("https://example.test/search?q=old&lang=en#results", "q", "red fox"))
EXPECTED OUTPUT
https://example.test/search?lang=en&q=red+fox#results
Follow the reasoning
  1. Remove every old q pair and add the new value.
  2. Check that the fragment remains after the newly encoded query.
03 / 03

Check duplicates, blanks, Unicode, and the trust boundary

Test a query with two unrelated tag values and an empty item. The function should remove old q values but not reorder or delete the other pairs. A Unicode search term demonstrates why manual string concatenation is fragile: the human-readable text must become a valid encoded query value. This is a useful boundary test because it exercises both UTF-8 encoding and reserved characters in one call.

Parsing and encoding do not validate a destination. A URL may be syntactically split while still pointing to a host or scheme your application should never contact. This lesson does not make requests. If you later pass user-controlled URLs to a network client, establish an allowlist for scheme and host and consider redirects and address resolution as part of that separate security design. The checked exercise is deliberately limited to deterministic URL transformation.

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

def set_query_parameter(url, key, value):
    p = urlsplit(url)
    q = [(k, v) for k, v in parse_qsl(p.query, keep_blank_values=True) if k != key]
    q.append((key, value))
    return urlunsplit((p.scheme, p.netloc, p.path, urlencode(q), p.fragment))

print(set_query_parameter("https://example.test/x?tag=a&tag=b&empty=&q=1#part", "q", "2"))
print(set_query_parameter("https://example.test/x", "q", "café & tea"))
EXPECTED OUTPUT
https://example.test/x?tag=a&tag=b&empty=&q=2#part
https://example.test/x?q=caf%C3%A9+%26+tea
Follow the reasoning
  1. Check both repeated unrelated tags and empty= in the first result.
  2. Check UTF-8 encoding, space-to-plus conversion, and encoded ampersand in the second result.

Try it in Python

Edit the example and run it. Python starts in your browser the first time you click Run.

Python console

Ready to run
Need input()? Add one value per line
Ctrl / ⌘ + Enter to run
OUTPUT
Your output appears here.
Your turnComplete set_query_parameter(url, key, value). Split the URL, parse its query with keep_blank_values=True, remove every pair whose key matches key, append (key, value), encode the resulting pairs, and rebuild the URL with its original fragment. The prepared call should print https://example.test/search?lang=en&q=red+fox#results. Do not make a network request.
Need a hint?

Use parts = urlsplit(url), pairs = [(name, item) for name, item in parse_qsl(parts.query, keep_blank_values=True) if name != key], then pairs.append((key, value)). Rebuild with urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(pairs), parts.fragment)).

Complete the task and select Check task to verify your code.

    Quick quiz

    Three questions. You can change your answers and try again.

    01.Why use parse_qsl instead of immediately converting the query to a dictionary?

    02.What does keep_blank_values=True preserve?

    03.Does urlsplit prove a URL is safe to request?

    Typical mistakes

    Everyone meets these errors. See what causes them and how to fix them.

    Appending a second question mark

    COMMON MISTAKE
    url = 'https://example.test/x?page=1'
    print(url + '?q=red fox')
    FIX
    from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode
    url = 'https://example.test/x?page=1'
    p = urlsplit(url)
    q = parse_qsl(p.query, keep_blank_values=True) + [('q', 'red fox')]
    print(urlunsplit((p.scheme, p.netloc, p.path, urlencode(q), p.fragment)))

    What happens: The new text is added inside an existing query and the space is not encoded.
    Build one query from pairs, then reassemble the components once.

    Losing repeated keys in a dictionary

    COMMON MISTAKE
    params = dict([('tag', 'a'), ('tag', 'b')])
    FIX
    from urllib.parse import urlencode
    params = [('tag', 'a'), ('tag', 'b')]
    print(urlencode(params))

    What happens: Only the final tag value remains in the dictionary.
    Keep the ordered pair list when multiple values for a key are allowed.

    Browse this lesson’s problem fixes

    Sources and further reading