Desk Clarity

A field guide to everyday digital problems.

Developer tools / Practical guide

Python Parse Qs Drops Empty Values: Preserve Blanks and Repeated Keys Explicitly

A query parameter with an empty value disappears from the parsed mapping.

By ON Lab2 min readSources checked November 10, 2026 (KST)

parse_qs drops blank values by default. Set keep_blank_values=True when an explicitly empty field is meaningful, and keep the list representation for repeated keys.

Check the failure with a small fixture

A query parameter with an empty value disappears from the parsed mapping.

from urllib.parse import parse_qs, parse_qsl, urlencode
query = "label=&tag=red&tag=blue&flag"
assert parse_qs(query) == {"tag": ["red", "blue"]}
preserved = parse_qs(query, keep_blank_values=True)
assert preserved == {"label": [""], "tag": ["red", "blue"], "flag": [""]}
assert "missing" not in preserved
pairs = parse_qsl(query, keep_blank_values=True)
assert pairs == [("label", ""), ("tag", "red"), ("tag", "blue"), ("flag", "")]
encoded = urlencode(preserved, doseq=True)
assert parse_qs(encoded, keep_blank_values=True) == preserved

What the result means

The preserved mapping distinguishes an absent field from a present empty string, but it does not preserve the textual difference between flag and flag=. Each mapping value is a list because one query key can occur more than once. Flattening every list to its first element silently discards additional values.

Choose the correction for your caller

Define your application contract for missing, blank and repeated fields before parsing. If order and duplicate occurrences are important, use parse_qsl and keep the returned sequence of pairs. When serializing a mapping of lists, doseq=True emits one occurrence per list element. Do not expect a decode-encode roundtrip to preserve exact percent-encoding, separator spelling or original bytes: it preserves the represented data under the chosen rules. Reject unexpected repeated scalar fields rather than selecting an arbitrary value.

Limits and the next check

Use max_num_fields where appropriate to bound parser work on untrusted queries; it is available in Python 3.8 and later. Pass a query component, such as urlsplit(url).query, rather than the complete URL. This local fixture sends no requests and does not implement a full request-validation policy.

Related: Compare a related data or execution contract.

The embedded Python example was executed as an isolated local script on November 10, 2026 (KST), using Python 3.11.6. It demonstrates the stated boundary; it is not a test of your application or a promise of compatibility with every Python release.

Sources & further reading