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.
Split first, decode after
Section titled “Split first, decode after”fn main() { let url = "https://example.com/search?q=rust&lang=en&tag=math%2Cscience&flag"; let query = url.split_once('?').map_or(url, |(_, query)| query); for pair in query.split('&') { match pair.split_once('=') { Some((key, value)) => println!("{key} -> {value}"), None => println!("{pair} -> (no value)"), } }}q -> rustlang -> entag -> math%2Cscienceflag -> (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-decoding
Section titled “Percent-decoding”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.
fn hex_digit(byte: u8) -> Option<u8> { match byte { b'0'..=b'9' => Some(byte - b'0'), b'a'..=b'f' => Some(byte - b'a' + 10), b'A'..=b'F' => Some(byte - b'A' + 10), _ => None, }}
fn percent_decode(input: &str) -> Result<String, String> { let bytes = input.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut i = 0; while i < bytes.len() { if bytes[i] == b'%' { let hi = bytes.get(i + 1).and_then(|b| hex_digit(*b)); let lo = bytes.get(i + 2).and_then(|b| hex_digit(*b)); match (hi, lo) { (Some(hi), Some(lo)) => { out.push(hi * 16 + lo); i += 3; } _ => return Err(format!("bad escape at byte {i}")), } } else { out.push(bytes[i]); i += 1; } } String::from_utf8(out).map_err(|_| "not valid UTF-8".to_string())}
fn main() { for raw in [ "math%2Cscience", "Ada%20Lovelace", "price%26dir", "100%25", "100%", "%zz", "%FF", ] { match percent_decode(raw) { Ok(decoded) => println!("{raw:>16} -> {decoded:?}"), Err(e) => println!("{raw:>16} -> error: {e}"), } }} 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-8Split 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 space that becomes a plus
Section titled “The space that becomes a plus”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:
$ node -e "console.log(new URLSearchParams({q: 'a b', c: 'C++'}).toString())"q=a+b&c=C%2B%2BSo 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:
$ 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 whole parser
Section titled “The whole parser”The decoder from above, with the plus rule folded in as a flag, and the split from the first block:
fn hex_digit(byte: u8) -> Option<u8> { match byte { b'0'..=b'9' => Some(byte - b'0'), b'a'..=b'f' => Some(byte - b'a' + 10), b'A'..=b'F' => Some(byte - b'A' + 10), _ => None, }}
fn percent_decode(input: &str, plus_is_space: bool) -> Result<String, String> { let bytes = input.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut i = 0; while i < bytes.len() { let byte = if plus_is_space && bytes[i] == b'+' { b' ' } else { bytes[i] }; if byte == b'%' { let hi = bytes.get(i + 1).and_then(|b| hex_digit(*b)); let lo = bytes.get(i + 2).and_then(|b| hex_digit(*b)); match (hi, lo) { (Some(hi), Some(lo)) => { out.push(hi * 16 + lo); i += 3; } _ => return Err(format!("bad escape at byte {i}")), } } else { out.push(byte); i += 1; } } String::from_utf8(out).map_err(|_| "not valid UTF-8".to_string())}
fn parse_query(query: &str, plus_is_space: bool) -> Result<Vec<(String, String)>, String> { query .split('&') .filter(|pair| !pair.is_empty()) .map(|pair| { let (key, value) = pair.split_once('=').unwrap_or((pair, "")); Ok(( percent_decode(key, plus_is_space)?, percent_decode(value, plus_is_space)?, )) }) .collect()}
fn main() { let query = "q=C%2B%2B&sort=price+desc&flag&tag=a%26b"; for (key, value) in parse_query(query, true).unwrap() { println!("{key} = {value:?}"); } println!(); for raw in ["q=C%2B%2B", "q=C++", "q=Ada+Lovelace"] { let form = parse_query(raw, true).unwrap(); let url = parse_query(raw, false).unwrap(); println!("{raw:>14} form: {:?} url: {:?}", form[0].1, url[0].1); }}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.
- How to split a string —
splitandsplit_once, the two cuts this page is built on - How to read a JSON config file — the same chapter, another format
- Back to Working with JSON, TOML and CSV