A fast, flexible query string encoding/decoding library for Swift and Objective-C.
Ported from qs for JavaScript.
- Nested maps & lists:
foo[bar][baz]=qux⇄["foo": ["bar": ["baz": "qux"]]] - Multiple list formats (indices, brackets, repeat, comma)
- Dot-notation (
a.b=c) and optional dot-encoding (settingdecodeDotInKeysautomatically enables dot notation) - UTF‑8 and ISO‑8859‑1 charsets; optional charset sentinel (
utf8=✓) - Custom encoders/decoders, sorting, filtering, strict/null handling
- Deterministic ordering with
OrderedDictionary(swift-collections)
- Swift 5.10+
- Platforms: macOS 12+, iOS 13+, tvOS 13+, watchOS 8+
- File → Add Package Dependencies…
- Enter: https://github.com/techouse/qs-swift
- Add the Qs library to your target.
// in your Package.swift
dependencies: [
.package(url: "https://github.com/techouse/qs-swift", from: "1.5.0")
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "QsSwift", package: "qs-swift")
]
)
]import QsSwift
// Decode
let decoded: [String: Any] = try Qs.decode("foo[bar]=baz&foo[list][]=a&foo[list][]=b")
// decoded == ["foo": ["bar": "baz", "list": ["a", "b"]]]
// Encode
let encoded: String = try Qs.encode(["foo": ["bar": "baz"]])
// encoded == "foo%5Bbar%5D=baz"// Decode
let obj: [String: Any] = try Qs.decode("a=c")
// ["a": "c"]
// Encode
let qs: String = try Qs.encode(["a": "c"])
// "a=c"try Qs.decode("foo[bar]=baz")
// ["foo": ["bar": "baz"]]
try Qs.decode("a%5Bb%5D=c")
// ["a": ["b": "c"]]
try Qs.decode("foo[bar][baz]=foobarbaz")
// ["foo": ["bar": ["baz": "foobarbaz"]]]Beyond the configured depth, the remainder is kept literally:
let r = try Qs.decode("a[b][c][d][e][f][g][h][i]=j")
// r["a"]?["b"]?["c"]?["d"]?["e"]?["f"]?["[g][h][i]"] == "j"Set strictDepth: true to throw instead of collapsing the remainder when the limit is exceeded.
Override depth:
let r = try Qs.decode("a[b][c][d][e][f][g][h][i]=j", options: .init(depth: 1))
// r["a"]?["b"]?["[c][d][e][f][g][h][i]"] == "j"try Qs.decode("a=b&c=d", options: .init(parameterLimit: 1))
// ["a": "b"]
try Qs.decode("?a=b&c=d", options: .init(ignoreQueryPrefix: true))
// ["a": "b", "c": "d"]try Qs.decode("a=b;c=d", options: .init(delimiter: StringDelimiter(";")))
// ["a": "b", "c": "d"]
let delim = try RegexDelimiter("[;,]")
try Qs.decode("a=b;c=d", options: .init(delimiter: delim))
// ["a": "b", "c": "d"]try Qs.decode("a.b=c", options: .init(allowDots: true))
// ["a": ["b": "c"]]
let r = try Qs.decode(
"name%252Eobj.first=John&name%252Eobj.last=Doe",
options: .init(decodeDotInKeys: true)
)
// ["name.obj": ["first": "John", "last": "Doe"]]Note: decodeDotInKeys implies allowDots; you don’t need to set both.
try Qs.decode("foo[]&bar=baz", options: .init(allowEmptyLists: true))
// ["foo": [], "bar": "baz"]
try Qs.decode("foo=bar&foo=baz")
// ["foo": ["bar", "baz"]]
try Qs.decode("foo=bar&foo=baz", options: .init(duplicates: .first))
// ["foo": "bar"]
try Qs.decode("foo=bar&foo=baz", options: .init(duplicates: .last))
// ["foo": "baz"]By default, QsSwift matches Node qs and wraps object/scalar conflicts into an array:
try Qs.decode("a[b]=c&a=d")
// ["a": [["b": "c"], "d"]]Set strictMerge: false to preserve the legacy behavior that merges the scalar as a property name:
try Qs.decode("a[b]=c&a=d", options: .init(strictMerge: false))
// ["a": ["b": "c", "d": true]]try Qs.decode("a=%A7", options: .init(charset: .isoLatin1))
// ["a": "§"]
try Qs.decode(
"utf8=%E2%9C%93&a=%C3%B8",
options: .init(charset: .isoLatin1, charsetSentinel: true)
)
// ["a": "ø"]
try Qs.decode(
"utf8=%26%2310003%3B&a=%F8",
options: .init(charset: .utf8, charsetSentinel: true)
)
// ["a": "ø"]try Qs.decode(
"a=%26%239786%3B",
options: .init(charset: .isoLatin1, interpretNumericEntities: true)
)
// ["a": "☺"]Heads-up: If you also enable comma: true, entity interpretation happens after comma processing. When you use list syntax like a[]=..., a comma-joined scalar stays a single element (e.g. ["1,☺"]) inside the list, matching the library’s tests and cross-port behavior.
try Qs.decode("a[]=b&a[]=c")
// ["a": ["b", "c"]]
try Qs.decode("a[1]=c&a[0]=b")
// ["a": ["b", "c"]]
try Qs.decode("a[1]=b&a[15]=c")
// ["a": ["b", "c"]]
try Qs.decode("a[]=&a[]=b")
// ["a": ["", "b"]]Large indices become a map by default:
let r = try Qs.decode("a[100]=b")
// ["a": ["100": "b"]]Disable list parsing:
let r = try Qs.decode("a[]=b", options: .init(parseLists: false))
// ["a": ["0": "b"]]Mix notations:
let r = try Qs.decode("a[0]=b&a[b]=c")
// ["a": ["0": "b", "b": "c"]]Comma-separated values:
let r = try Qs.decode("a=b,c", options: .init(comma: true))
// ["a": ["b", "c"]]With throwOnLimitExceeded: true, each comma token is checked against listLimit before splitting,
including []= groups. For example, a[]=1,2,3 is valid at limit 3 and remains one nested outer
element; a[]=1,2,3,4 throws. Outer list growth is limited separately. Without throwing, oversized
[]= groups retain their nested array shape.
When a soft overflow has already produced a numeric-keyed map, later flat comma arrays append their
elements at successive indices. Bracket-push groups spread only their outer wrapper: with
comma: true, listLimit: 2, a[]=1&a[]=2&a[]=3&a[]=4,5 becomes
["a": ["0": "1", "1": "2", "2": "3", "3": ["4", "5"]]]. Incoming dictionaries remain single values.
try Qs.encode(["a": "b"])
// "a=b"
try Qs.encode(["a": ["b": "c"]])
// "a%5Bb%5D=c"Disable URI encoding for readability:
try Qs.encode(["a": ["b": "c"]], options: .init(encode: false))
// "a[b]=c"Values-only encoding:
let input: [String: Any] = [
"a": "b",
"c": ["d", "e=f"],
"f": [["g"], ["h"]],
]
try Qs.encode(input, options: .init(encodeValuesOnly: true))
// "a=b&c[0]=d&c[1]=e%3Df&f[0][0]=g&f[1][0]=h"Custom encoder:
let enc: ValueEncoder = { value, _, _ in
// e.g. map "č" → "c", otherwise describe
if let s = value as? String, s == "č" {
return "c"
}
return String(describing: value ?? "")
}
try Qs.encode(["a": ["b": "č"]], options: .init(encoder: enc))
// "a[b]=c"Encoding is unbounded by default (EncodeOptions.depth == .max). Set an integer limit to reject
deeper values with EncodeError.depthExceeded(maxDepth:):
try Qs.encode(["a": "b"], options: .init(depth: 0)) // "a=b"
try Qs.encode(["a": ["b": "c"]], options: .init(depth: 1)) // "a%5Bb%5D=c"
// The nested example throws with depth: 0.Each top-level value starts at depth zero; child values, including array elements and comma-list serialization, consume a level. Negative limits reject any started value frame. Empty roots still produce an empty string. The check precedes filtering and leaf handling at an over-limit frame. The limit bounds serialization, not Objective-C input conversion or total input size.
// indices (default when encode=false)
try Qs.encode(["a": ["b", "c"]], options: .init(encode: false))
// "a[0]=b&a[1]=c"
// brackets
try Qs.encode(["a": ["b", "c"]], options: .init(listFormat: .brackets, encode: false))
// "a[]=b&a[]=c"
// repeat
try Qs.encode(["a": ["b", "c"]], options: .init(listFormat: .repeatKey, encode: false))
// "a=b&a=c"
// comma
try Qs.encode(["a": ["b", "c"]], options: .init(listFormat: .comma, encode: false))
// "a=b,c"Note: When you select .comma, you can set commaRoundTrip = true to append [] for single‑element lists so they can decode back into arrays. Set commaCompactNulls = true to drop NSNull/nil entries before joining (e.g., ["one", NSNull(), nil, "two"] → one,two). If all entries are NSNull/nil, the key is omitted; if filtering leaves a single item and commaRoundTrip = true, [] is preserved.
try Qs.encode(["a": ["b": ["c": "d", "e": "f"]]], options: .init(encode: false))
// "a[b][c]=d&a[b][e]=f"
try Qs.encode(["a": ["b": ["c": "d", "e": "f"]]], options: .init(allowDots: true, encode: false))
// "a.b.c=d&a.b.e=f"Encode dots in keys:
try Qs.encode(
["name.obj": ["first": "John", "last": "Doe"]],
options: .init(allowDots: true, encodeDotInKeys: true)
)
// "name%252Eobj.first=John&name%252Eobj.last=Doe"This also applies to scalar and null top-level keys:
try Qs.encode(["a.b": "c"], options: .init(encodeDotInKeys: true))
// "a%252Eb=c"
try Qs.encode(["a.b": "c"], options: .init(encodeDotInKeys: true, encodeValuesOnly: true))
// "a%2Eb=c"Normal key encoding escapes the inserted % again, preserving a literal dot when decoded with
decodeDotInKeys: true. With encodeValuesOnly: true or encode: false, the top-level key contains
only %2E; decoding with dot notation can therefore split it as a structural dot.
Empty lists, nulls, and other niceties:
// Allow empty lists (order preserved with OrderedDictionary input)
try Qs.encode(["foo": [Any](), "bar": "baz"], options: .init(allowEmptyLists: true, encode: false))
// e.g. "foo[]&bar=baz"
try Qs.encode(["a": ""]) // "a="
try Qs.encode(["a": [Any]()]) // ""
try Qs.encode(["a": ["b": [Any]()]]) // ""
try Qs.encode(["a": NSNull(), "b": Undefined()]) // "a="
try Qs.encode(["a": "b", "c": "d"], options: .init(addQueryPrefix: true)) // "?a=b&c=d"
try Qs.encode(["a": "b", "c": "d"], options: .init(delimiter: ";")) // "a=b;c=d"let date = Date(timeIntervalSince1970: 0.007) // 7 ms since epoch
// Default ISO-8601 with millisecond precision (encode=false example)
try Qs.encode(["a": date], options: .init(encode: false))
// "a=1970-01-01T00:00:00.007Z"
// Custom serializer (epoch millis)
try Qs.encode(
["a": date],
options: .init(
dateSerializer: { d in String(Int((d.timeIntervalSince1970 * 1000.0).rounded())) },
encode: false
)
)
// "a=7"// Sort keys
let sort: Sorter = { a, b in
let la = String(describing: a ?? "")
let lb = String(describing: b ?? "")
return la.compare(lb).rawValue // -1/0/1
}
try Qs.encode(["a": "c", "z": "y", "b": "f"], options: .init(encode: false, sort: sort))
// "a=c&b=f&z=y"
// Function filter (drop/transform)
let date = Date(timeIntervalSince1970: 0.123) // 123 ms
let filter = FunctionFilter { prefix, value in
switch prefix {
case "b": return Undefined()
case "e[f]":
if let d = value as? Date {
return Int((d.timeIntervalSince1970 * 1000.0).rounded())
}
case "e[g][0]":
if let n = value as? NSNumber {
return n.intValue * 2
}
if let i = value as? Int {
return i * 2
}
default: break
}
return value
}
let input: [String: Any] = [
"a": "b",
"c": "d",
"e": ["f": date, "g": [2]],
]
try Qs.encode(input, options: .init(encode: false, filter: filter))
// "a=b&c=d&e[f]=123&e[g][0]=4"
// Iterable filter (whitelist keys/indices)
try Qs.encode(["a": "b", "c": "d", "e": "f"], options: .init(encode: false, filter: IterableFilter(["a", "e"])))
// "a=b&e=f"try Qs.encode(["a": "b c"]) // "a=b%20c" (RFC 3986 default)
try Qs.encode(["a": "b c"], options: .init(format: .rfc3986)) // "a=b%20c"
try Qs.encode(["a": "b c"], options: .init(format: .rfc1738)) // "a=b+c"Use the Foundation helpers when you want to append QsSwift output to URLComponents or URL without routing already
encoded bracket notation through Foundation's decoded query APIs.
import Foundation
import QsSwift
var components = URLComponents(string: "https://api.example.com/products")!
try components.appendQsQueryItems([
"filter": [
"where": [
"name": "John",
"age": ["gte": 30],
],
],
"tags": ["a", "b"],
])
components.url?.absoluteString
// "https://api.example.com/products?filter%5Bwhere%5D%5Bname%5D=John&filter%5Bwhere%5D%5Bage%5D%5Bgte%5D=30&tags%5B0%5D=a&tags%5B1%5D=b"Immutable URL usage returns a new URL and preserves the original:
let url = URL(string: "https://api.example.com/products?existing=x#details")!
let next = try url.appendingQsQueryItems([
"filter": ["where": ["name": "John"]],
"tags": ["a", "b"],
])
next.absoluteString
// "https://api.example.com/products?existing=x&filter%5Bwhere%5D%5Bname%5D=John&tags%5B0%5D=a&tags%5B1%5D=b#details"Repeated keys and custom delimiters are preserved:
var semicolon = URLComponents(string: "https://api.example.com/products?existing=x")!
try semicolon.appendQsQueryItems(
["tag": ["swift", "ios"]],
options: .init(listFormat: .repeatKey, delimiter: ";")
)
semicolon.percentEncodedQuery
// "existing=x;tag=swift;tag=ios"The helpers append to percentEncodedQuery, not queryItems. queryItems treats names and values as decoded text and
can double-encode QsSwift output (%5B becoming %255B). The URL helpers force URL-safe QsSwift encoding internally,
even if the supplied options use encode: false or encodeValuesOnly: true.
Non-goal: this integration does not add Alamofire, Vapor, AsyncHTTPClient, or other framework-specific helpers.
Query strings don’t have a native null concept, so Qs uses a few conventions to mirror “JSON-style” semantics as closely as possible:
NSNull()– use this to represent an explicit “null-like” value.Undefined()– a special sentinel provided byQsto mean “omit this key entirely”.""(empty string) – a real, present-but-empty value.
| Input value | Default (strictNullHandling: false) |
With strictNullHandling: true |
With skipNulls: true |
|---|---|---|---|
"foo" |
a=foo |
a=foo |
a=foo |
"" (empty string) |
a= |
a= |
a= |
NSNull() |
a= |
a (no = sign) |
(omitted) |
Undefined() |
(omitted) | (omitted) | (omitted) |
Examples:
try Qs.encode(["a": NSNull()])
// "a="
try Qs.encode(["a": NSNull()], options: .init(strictNullHandling: true))
// "a" // bare key, no "="
try Qs.encode(["a": NSNull()], options: .init(skipNulls: true))
// "" // key omitted
try Qs.encode(["a": Undefined()])
// "" // always omitted, regardless of options| Input token | Default (strictNullHandling: false) |
With strictNullHandling: true |
|---|---|---|
a= |
["a": ""] |
["a": ""] |
a |
["a": ""] |
["a": NSNull()] |
Examples:
try Qs.decode("a&b=")
// ["a": "", "b": ""]
try Qs.decode("a&b=", options: .init(strictNullHandling: true))
// ["a": NSNull(), "b": ""]- In Foundation's JSONSerialization,
NSNullis the conventional stand-in for JSONnull. → In Qs, useNSNull()to mean anull-like value. - In Codable/JSONEncoder, whether missing keys are emitted or omitted often depends on how your model is
encoded (
encodevsencodeIfPresent). → InQs, useUndefined()to always omit a key from the output. - There is no native “null” in query strings, so preserving a true “null round-trip” requires using:
NSNull()onencodeandstrictNullHandling: true(so it renders as a bare key), andstrictNullHandling: trueondecode(so bare keys come back asNSNull()).
Round-trip tip:
// Encode with a null-like value:
let out = try Qs.encode(["a": NSNull()], options: .init(strictNullHandling: true))
// "a"
// Decode back to NSNull:
let back = try Qs.decode(out, options: .init(strictNullHandling: true))
// ["a": NSNull()]If you simply want to drop keys when a value is not present, prefer Undefined() (or skipNulls: true when values are
NSNull()), rather than encoding NSNull() itself.
Qs.decode(_:, options:) -> [String: Any]Qs.encode(_:, options:) -> StringDecodeOptions/EncodeOptions– configuration knobsDuplicates/ListFormat/Format– enums matching qs.js semanticsUndefined– sentinel used by filters to omit keys
- If
options.sort != nil, that comparator decides order. - If
options.sort == nilandoptions.encode == false, key order follows input traversal (useOrderedDictionaryfor stability). - Arrays always preserve input order.
- Keep
depthandparameterLimitreasonable for untrusted inputs (defaults are sane). allowEmptyLists,allowSparseLists, andparseListslet you tune behavior for edge cases.- Use
strictNullHandlingto differentiatenil(no=) from empty string (=).
A tiny micro‑bench harness lives in Bench/ (separate SPM package). It’s excluded from the main library.
cd Bench
make profileAn Objective‑C bridge is included as QsObjC (facade + delegate-style hooks).
See the QsObjC README for installation, options, and examples. → Docs
Experimental (Swift 6.0+)
On Linux, QsSwift uses ReerKit’s WeakMap
to emulate NSMapTable.weakToWeakObjects() (weak keys and weak
values) for the encoder’s cycle‑detection side‑channel. This works around CoreFoundation APIs that aren’t available in
swift‑corelibs‑foundation on Linux.
- Some tests that construct self‑referential
NSArray/NSDictionarygraphs are wrapped inwithKnownIssuebecause swift‑corelibs‑foundation can crash when creating those graphs. (Apple platforms are unaffected.) - CI includes an experimental Ubuntu job and is marked
continue-on-errorwhile Linux behavior stabilizes.
Special thanks to the authors of qs for JavaScript:
| Port | Repository | Package |
|---|---|---|
| Dart | techouse/qs | |
| Python | techouse/qs_codec | |
| Kotlin / JVM + Android AAR | techouse/qs-kotlin | |
| .NET / C# | techouse/qs-net | |
| Rust | techouse/qs_rust | |
| Node.js (original) | ljharb/qs |
BSD 3‑Clause © techouse
