Skip to content
Select theme

How to parse a query string

Short answer: Split the query on &, split each pair on the first =, then percent-decode both halves — the standard library has no query parser, and those three steps are the whole job. The one decision left is a literal +: the form-encoding rules say it is a space, and RFC 3986 says it is a plus.

The query string is whatever follows the ? in a URL, and the same string again in an application/x-www-form-urlencoded request body without the ?. This continues the json-demo project from the YAML page; no crate is added, because the standard library is enough.

q -> rust
lang -> en
tag -> math%2Cscience
flag -> (no value)

split_once cuts at the first =, so a=b=c gives the key a and the value b=c. Two separators in a row leave an empty pair, and a bare flag has no value at all; both are legal. The parser at the end of this page drops the empty pairs and gives a bare key an empty value.

Percent-encoding is how a value carries the characters the syntax has claimed for itself: %26 is &, %3D is =, %20 is a space. Two hex digits, one byte.

math%2Cscience -> "math,science"
Ada%20Lovelace -> "Ada Lovelace"
price%26dir -> "price&dir"
100%25 -> "100%"
100% -> error: bad escape at byte 3
%zz -> error: bad escape at byte 0
%FF -> error: not valid UTF-8

Split on & first and decode second. The pair sort=price%26dir is one pair whose value is price&dir; with a raw & the same text arrives as two pairs, sort=price and dir. Decoding first would cut on an & that was never a separator.

The decoder collects bytes and checks UTF-8 once at the end, because an escape can encode half a character — %E2%82%AC is the three bytes of €. A malformed escape is this decoder’s error; browsers pass one through unchanged, and a lenient parser keeps the raw text instead. Either is defensible; pick one and write it down.

The other rule comes from the encoder’s side. A browser submitting a form encodes the field value as application/x-www-form-urlencoded: a space becomes +, and a literal + becomes %2B. Node 22.22.2, on the same rules:

Terminal window
$ node -e "console.log(new URLSearchParams({q: 'a b', c: 'C++'}).toString())"
q=a+b&c=C%2B%2B

So a parser has to decide what an incoming + means, and the two authorities disagree. RFC 3986, which defines the URL syntax, says only %XX is an escape and + is itself. The form rules say a + is a space, and that is what the rest of the world reads — Node 22.22.2 and Python 3.13.12 both:

Terminal window
$ node -e "const p = new URLSearchParams('q=C++&sort=price+desc'); console.log(p.get('q'), '|', p.get('sort'))"
C | price desc
$ python3 -c "import urllib.parse; print(urllib.parse.parse_qs('q=C++&sort=price+desc'))"
{'q': ['C '], 'sort': ['price desc']}

A search for C++ typed into a browser arrives as q=C%2B%2B and decodes to C++ under either rule. Hand-written into the URL bar as q=C++, it reaches a form-rules parser as C followed by two spaces. %2B is the only form that means the same thing everywhere.

The decoder from above, with the plus rule folded in as a flag, and the split from the first block:

q = "C++"
sort = "price desc"
flag = ""
tag = "a&b"
q=C%2B%2B form: "C++" url: "C++"
q=C++ form: "C " url: "C++"
q=Ada+Lovelace form: "Ada Lovelace" url: "Ada+Lovelace"

The Vec keeps duplicate keys — tag=a&tag=b is two pairs, not one overwritten — and a full URL has its ? cut away before the split, as in the first block. Pass true when the string came from a form or a client that follows the form rules, which is most of them; pass false for a strict RFC 3986 reading, or when you control both ends and the plus is data.

For production code the url crate does all of this: url::Url::query_pairs hands back the decoded pairs, taking the form-rules reading. The HTTP chapter has a page for parsing URLs.