Skip to content

Build and parse URL query strings snippet

Query strings look like string concatenation and punish it: values containing &, =, spaces, or non-ASCII corrupt the URL the moment you skip encoding — and hand-rolling the encoding invites the classic + vs %20 mixup.

Query strings look like string concatenation and punish it: values containing &, =, spaces, or non-ASCII corrupt the URL the moment you skip encoding — and hand-rolling the encoding invites the classic + vs %20 mixup. Every language has a dedicated params type (URLSearchParams, url.Values, urlencode, HttpUtility.ParseQueryString); use it for BOTH directions and you get repeated keys, order, and escaping handled. The remaining trap is multi-value keys: params.get('tag') returns ONE of many — the getAll form is the one you wanted.

Runnable recipe · 12 languagesOpen the utm-link-builder tool →
Text & Parsingurlquery-stringencodinghttpparams

Every language

12 implementations, copy-ready. One at a time with syntax highlighting, or all inline.

JSJavaScript
const url = new URL('https://x.test/search');

// build — set replaces, append repeats
url.searchParams.set('q', 'cafés & tea');
url.searchParams.append('tag', 'drinks');
url.searchParams.append('tag', 'sweet');

console.log(url.toString());
// https://x.test/search?q=caf%C3%A9s+%26+tea&tag=drinks&tag=sweet

// parse
console.log(url.searchParams.get('tag'));    // 'drinks' — first of many
console.log(url.searchParams.getAll('tag')); // ['drinks', 'sweet']
for (const [key, value] of url.searchParams) console.log(key, value);

URLSearchParams encodes spaces as + (form encoding) — fine for query strings, wrong for path segments. get() hands you the first value only; getAll() is the multi-value form.

TSTypeScript
export function buildQuery(
  params: Record<string, string | string[]>,
): string {
  const sp = new URLSearchParams();
  for (const [key, value] of Object.entries(params)) {
    for (const v of Array.isArray(value) ? value : [value]) {
      sp.append(key, v);
    }
  }
  return sp.toString();
}

buildQuery({ q: 'cafés & tea', tag: ['drinks', 'sweet'] });
// 'q=caf%C3%A9s+%26+tea&tag=drinks&tag=sweet'

Same URLSearchParams engine, typed — Record<string, string | string[]> is the honest shape for repeated keys; append (not set) so arrays survive as repeats.

GoGo
import (
	"fmt"
	"net/url"
)

// build
v := url.Values{}
v.Set("q", "cafés & tea")
v.Add("tag", "drinks")
v.Add("tag", "sweet")
fmt.Println(v.Encode()) // q=caf%C3%A9s+%26+tea&tag=drinks&tag=sweet

// parse — url.Values is map[string][]string, repeats built in
q, _ := url.ParseQuery("q=x&tag=a&tag=b")
fmt.Println(q["tag"]) // [a b]

Encode sorts keys alphabetically — order is NOT preserved. ParseQuery returns map[string][]string, so multi-value keys arrive as slices; a plain index on a missing key yields an empty slice, not nil-crash.

RsRust
use url::form_urlencoded::{parse, Serializer};

fn main() {
    // build
    let mut ser = Serializer::new(String::new());
    ser.append_pair("q", "cafés & tea");
    ser.append_pair("tag", "drinks");
    ser.append_pair("tag", "sweet");
    let query = ser.finish();
    println!("{query}"); // q=caf%C3%A9s+%26+tea&tag=drinks&tag=sweet

    // parse — yields every (key, value) pair, repeats included
    for (k, v) in parse(query.as_bytes()) {
        println!("{k}={v}");
    }
}

The url crate's query_pairs_mut() is the mutate-a-URL-in-place form: url.query_pairs_mut().append_pair("tag", "x"). serde_urlencoded is the serde route when you want a struct instead of pairs.

PHPPHP
<?php
// build — repeated keys need the bracket form; encoding is handled
$query = http_build_query([
    'q' => 'cafés & tea',
    'tag' => ['drinks', 'sweet'],
]);
echo $query, PHP_EOL; // q=caf%C3%A9s+%26+tea&tag%5B0%5D=drinks&tag%5B1%5D=sweet

// parse — the result array is REQUIRED since PHP 8
parse_str($query, $params);
print_r($params['tag']); // ['drinks', 'sweet']

parse_str without the second arg injects variables into scope — required (and removed otherwise) since PHP 8. And a plain repeated key (tag=a&tag=b) keeps only the LAST value; arrays come only from tag[] syntax, the PHP-ism other languages don't share.

PyPython
from urllib.parse import parse_qs, urlencode

# build — a list of pairs keeps order AND repeated keys (a dict cannot)
query = urlencode([
    ('q', 'cafés & tea'),
    ('tag', 'drinks'),
    ('tag', 'sweet'),
])
print(query)  # q=caf%C3%A9s+%26+tea&tag=drinks&tag=sweet

# parse
params = parse_qs(query)
print(params['tag'])  # ['drinks', 'sweet']

parse_qs returns a LIST per key and drops blank values unless keep_blank_values=True ('a=&b=1' loses 'a'). parse_qsl gives flat (key, value) pairs when you don't want the lists.

C#C#
using System.Text;

var builder = new UriBuilder("https://x.test/search");
var sb = new StringBuilder();

foreach (var (key, value) in new[] {
    ("q", "cafés & tea"),
    ("tag", "drinks"),
    ("tag", "sweet"),
})
{
    if (sb.Length > 0) sb.Append('&');
    sb.Append(Uri.EscapeDataString(key))
      .Append('=')
      .Append(Uri.EscapeDataString(value));
}
builder.Query = sb.ToString();

Console.WriteLine(builder.Uri);
// https://x.test/search?q=caf%C3%A9s%20%26%20tea&tag=drinks&tag=sweet

UriBuilder + Uri.EscapeDataString is the portable no-ASP.NET build route (%20 style — both it and + decode fine). For parsing, HttpUtility.ParseQueryString (System.Web) returns a NameValueCollection whose indexer COMMA-JOINS repeated keys — GetValues("tag") is the multi-value form. Microsoft.AspNetCore.WebUtilities.QueryString is the modern alternative.

JvJava
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;

public class BuildQuery {
    // the JDK has no params type — join encoded pairs yourself
    static String buildQuery(List<String[]> pairs) {
        StringBuilder sb = new StringBuilder();
        for (String[] kv : pairs) {
            if (sb.length() > 0) sb.append('&');
            sb.append(URLEncoder.encode(kv[0], StandardCharsets.UTF_8))
              .append('=')
              .append(URLEncoder.encode(kv[1], StandardCharsets.UTF_8));
        }
        return sb.toString();
    }

    public static void main(String[] args) {
        String q = buildQuery(List.of(
            new String[]{"q", "cafés & tea"},
            new String[]{"tag", "drinks"},
            new String[]{"tag", "sweet"}));
        System.out.println(q); // q=caf%C3%A9s+%26+tea&tag=drinks&tag=sweet
    }
}

URLEncoder is form encoding — spaces become +, never %20 — and it is encode-ONLY: java.net.URI has no query parser (the honest stdlib gap; getRawQuery() hands you the raw string). Spring's UriComponentsBuilder fills both directions.

SwSwift
import Foundation

var comps = URLComponents(string: "https://x.test/search")!
comps.queryItems = [
    URLQueryItem(name: "q", value: "cafés & tea"),
    URLQueryItem(name: "tag", value: "drinks"),
    URLQueryItem(name: "tag", value: "sweet"),
]
print(comps.url!)
// https://x.test/search?q=caf%C3%A9s%20%26%20tea&tag=drinks&tag=sweet

// parse
let parsed = URLComponents(string: "https://x.test/search?tag=a&tag=b")!
for item in parsed.queryItems ?? [] {
    print("\(item.name)=\(item.value ?? "")")
}

URLComponents + URLQueryItem is the model Apple gives you. Percent-encoding follows RFC 3986 defaults — spaces become %20, not +. Assigning queryItems replaces the whole list (no append API), and two items with the same name both survive; group them yourself.

KtKotlin
import java.net.URI
import java.net.URLDecoder
import java.net.URLEncoder
import java.nio.charset.StandardCharsets

fun buildQuery(pairs: List<Pair<String, String>>): String =
    pairs.joinToString("&") { (k, v) ->
        "${URLEncoder.encode(k, StandardCharsets.UTF_8)}=${URLEncoder.encode(v, StandardCharsets.UTF_8)}"
    }

fun parseQuery(query: String): List<Pair<String, String>> =
    query.split('&').filter { it.isNotEmpty() }.map { pair ->
        val i = pair.indexOf('=')
        if (i < 0) pair to ""
        else URLDecoder.decode(pair.substring(0, i), StandardCharsets.UTF_8) to
             URLDecoder.decode(pair.substring(i + 1), StandardCharsets.UTF_8)
    }

fun main() {
    val q = buildQuery(listOf("q" to "cafés & tea", "tag" to "drinks", "tag" to "sweet"))
    println(q) // q=caf%C3%A9s+%26+tea&tag=drinks&tag=sweet

    println(URI("https://x.test/search?$q").rawQuery) // raw string, no splitting
    parseQuery(q).forEach { (k, v) -> println("$k=$v") }

Same JVM URLEncoder (+ for space) plus a small hand parse via URI.rawQuery splitting — the JDK ships no query parser, so the split-and-decode loop IS the recipe.

RbRuby
require 'uri'

# build — an array of [key, value] pairs keeps order and repeats
query = URI.encode_www_form([
  ['q', 'cafés & tea'], ['tag', 'drinks'], ['tag', 'sweet']
])
puts query # q=caf%C3%A9s+%26+tea&tag=drinks&tag=sweet

# parse
URI.decode_www_form(query).each { |k, v| puts "#{k}=#{v}" }

CGI.escape is + -style; URI.encode_www_form_component matches it — the component form for one value, the array form for a whole query. Rack::Utils.parse_nested_query handles Rails-style tag[] nesting.

ZigZig
const std = @import("std");

fn appendEncoded(out: []u8, len: usize, s: []const u8) usize {
    var i = len;
    for (s) |c| {
        const unreserved = std.ascii.isAlphanumeric(c) or
            c == '-' or c == '_' or c == '.' or c == '~';
        if (unreserved) {
            out[i] = c;
            i += 1;
        } else {
            out[i] = '%';
            out[i + 1] = "0123456789ABCDEF"[c >> 4];
            out[i + 2] = "0123456789ABCDEF"[c & 0xf];
            i += 3;
        }
    }
    return i;
}

pub fn main() !void {
    // build — no params type in std; percent-encode into a buffer by hand
    var buf: [256]u8 = undefined;
    var len: usize = 0;
    const pairs = [_][2][]const u8{
        .{ "q", "cafes & tea" },
        .{ "tag", "drinks" },
        .{ "tag", "sweet" },
    };
    for (pairs, 0..) |pair, i| {
        if (i != 0) {
            buf[len] = '&';
            len += 1;
        }
        len = appendEncoded(&buf, len, pair[0]);
        buf[len] = '=';
        len += 1;
        len = appendEncoded(&buf, len, pair[1]);
    }
    std.debug.print("{s}\n", .{buf[0..len]});

    // parse — std.Uri.parse handles the full URI; split the raw query yourself
    const uri = try std.Uri.parse("https://x.test/search?q=x&tag=a&tag=b");
    const query = uri.query orelse "";
    var it = std.mem.splitScalar(u8, query, '&');
    while (it.next()) |pair| std.debug.print("{s}\n", .{pair});
}

std.Uri can parse (std.http uses it) but not build — the buffer loop IS the recipe. Deliberately sticks to stable std APIs (std.ascii, std.mem.splitScalar) so it survives the Io-writer churn across Zig releases.

Keep going

Try the interactive utm-link-builder tool →