URLComponents quietly decodes what you carefully encoded

April 10, 2026 3 min read · ios

URLComponents is the modern, safe way to construct and modify URLs in Swift — it handles percent encoding automatically, so you don’t have to think about it. Until it silently changes the encoding and breaks the expected format. Most systems handle over-encoding gracefully; under-encoding can silently corrupt data.

The gotcha

URLComponents treats percent encoding inconsistently depending on how you use it. The key properties are queryItems, which decodes values automatically on read and re-encodes them on write, and percentEncodedQueryItems, which reads and writes the raw percent-encoded strings without any transformation.

  • On initialization: it preserves the exact encoding from the source URL
  • On modification: it re-encodes query parameters according to RFC 3986, discarding any “voluntary” percent encoding

Any percent-encoded character that doesn’t need to be encoded per the spec is “voluntary” — for example, %3A (:) and %2F (/) in ?callback=https%3A%2F%2Fapp.example.com. Modify queryItems and they’re gone.

A real example

Passing a URL as a query parameter is a common pattern in OAuth flows, deep linking, and 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)!

// Looks fine so far
print(components.queryItems!.first!.value)
// Output: https://app.example.com/auth?token=abc123

// Add another parameter
components.queryItems?.append(URLQueryItem(name: "state", value: "xyz"))

print(components.url!.absoluteString)
// ⚠️ percent encoding stripped — the nested `?` now breaks the outer URL
// https://api.example.com/redirect?callback=https://app.example.com/auth?token=abc123&state=xyz

The callback URL’s encoding is gone. Colons and slashes in query values are technically allowed by RFC 3986, so URLComponents decoded them — producing a URL that is spec-compliant but broken in practice.

WebKit in particular may parse the nested ?token=abc123 as part of the outer query string, fail to extract the callback value correctly, or reject the URL entirely.

The fix

To preserve existing encoding we can use percentEncodedQueryItems, but it requires that we encode any new values ourselves.

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)
// Output: https://api.example.com/redirect?callback=https%3A%2F%2Fapp.example.com%2Fauth%3Ftoken%3Dabc123&state=xyz

The original encoding survives. The new parameter is encoded correctly.

When to use what

queryItems — when you control both sides and standard URI encoding is fine.

percentEncodedQueryItems — when working with nested URLs in query parameters, or integrating with systems (like WebKit) that expect stricter encoding.

If you’re appending percent-encoded query items in multiple places, here’s a small extension to keep it 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
    }
}