URLComponents quietly decodes what you carefully encoded
The gotcha
URLComponents handles percent encoding for you, most of the time. The trap is that it behaves differently depending on how you touch it. Initialize from a URL string and it preserves the exact encoding you passed in. Modify the query through queryItems and it re-encodes everything according to RFC 3986, throwing away any “voluntary” percent encoding: characters like %3A (:) and %2F (/) that don’t need to be encoded per the spec.
The escape hatch is percentEncodedQueryItems. It reads and writes raw percent-encoded strings without touching them.
A real example
You’ll hit this whenever you pass a URL as a query parameter, like in OAuth flows, deep links, or analytics tracking:
let originalURL = URL(string: "https://api.example.com/redirect?callback=https%3A%2F%2Fapp.example.com%2Fauth%3Ftoken%3Dabc123")!
var components = URLComponents(url: originalURL, resolvingAgainstBaseURL: false)!
print(components.queryItems!.first!.value)
// https://app.example.com/auth?token=abc123
components.queryItems?.append(URLQueryItem(name: "state", value: "xyz"))
print(components.url!.absoluteString)
// https://api.example.com/redirect?callback=https://app.example.com/auth?token%3Dabc123&state=xyz
The callback URL’s encoding is gone. Colons, slashes, and the nested ? are technically allowed in query values by RFC 3986, so URLComponents decoded them, leaving you with a URL that’s spec-compliant but broken in practice. WebKit may parse the unescaped ? as a second query delimiter, fail to extract the callback value, or reject the URL outright.
The fix
Use percentEncodedQueryItems to preserve existing encoding. The tradeoff: you encode new values yourself.
let originalURL = URL(string: "https://api.example.com/redirect?callback=https%3A%2F%2Fapp.example.com%2Fauth%3Ftoken%3Dabc123")!
var components = URLComponents(url: originalURL, resolvingAgainstBaseURL: false)!
var items = components.percentEncodedQueryItems ?? []
let encodedValue = "xyz".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
items.append(URLQueryItem(name: "state", value: encodedValue))
components.percentEncodedQueryItems = items
print(components.url!.absoluteString)
// https://api.example.com/redirect?callback=https%3A%2F%2Fapp.example.com%2Fauth%3Ftoken%3Dabc123&state=xyz
The original encoding survives because nothing in the path touched it. Your new parameter rides alongside, encoded once.
When to use what
queryItems. When you control both sides and standard URI encoding is fine.
percentEncodedQueryItems. When you’re embedding nested URLs in query parameters, or handing the result to something like WebKit that expects stricter encoding.
A small extension keeps repeated usage tidy:
extension URLComponents {
mutating func appendPercentEncodedQueryItem(name: String, value: String) {
var items = percentEncodedQueryItems ?? []
let encodedName = name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
let encodedValue = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
items.append(URLQueryItem(name: encodedName, value: encodedValue))
percentEncodedQueryItems = items
}
}