What goes wrong when you build a URL with string concatenation, and how do URLComponents, percent-encoding sets and the plus sign behave?
Predict first
What do these URL and encoding calls produce?
import Foundation
var c = URLComponents(string: "https://api.example.com/search")!
c.queryItems = [URLQueryItem(name: "q", value: "swift & combine+rx"),
URLQueryItem(name: "tag", value: "a b"),
URLQueryItem(name: "note", value: "100%")]
print(c.url!.absoluteString)
print(URL(string: "https://example.com/a path") as Any)
print("a b/é".addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)!)
print("a&b=c".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!)
print(URL(string: "https://example.com/base/")!.appendingPathComponent("x y").absoluteString)
- …?q=swift%20%26%20combine+rx&tag=a%20b¬e=100%25 / the plus stays / urlQueryAllowed keeps &
- The plus becomes %2B and & stays raw
- URL(string:) returns nil for the path with a space
Mental model. A URL is a labelled parcel. Each part has its own rules for which characters need wrapping; reusing the wrapping for the label on the box as for the label on the contents corrupts one of them.
Never build a query by concatenating strings. URLComponents with URLQueryItem encodes each name and value for you, but know what it does not do. In the check, swift & combine+rx becomes swift%20%26%20combine+rx: the space and the ampersand are encoded, but the plus sign is left as a literal +, and many servers (anything that follows the form-encoding convention) decode a raw + as a space, so the search arrives as “swift & combine rx”. If a value can contain +, encode it yourself (replace + with %2B in percentEncodedQueryItems) after agreeing the convention with the backend. A literal % is encoded correctly (100%25).
The character sets are the other trap. .urlQueryAllowed is for a whole query string, so it leaves & and = unescaped (a&b=c comes back unchanged), which is wrong for a single value; likewise .urlPathAllowed encodes a space and non-ASCII (a%20b/%C3%A9) but keeps /. Use a custom set for one value, or better URLQueryItem. Modern Foundation is also more forgiving than it used to be: URL(string: "https://example.com/a path") now returns a percent-encoded URL instead of nil (older OS versions and other parsers return nil, so do not rely on either), and appendingPathComponent("x y") encodes the component. Build with URLComponents, keep the base URL and path segments separate, and test with awkward values (spaces, +, &, non-ASCII, emoji).
URLComponents encodes spaces, &, and % but leaves +; urlQueryAllowed is for a whole query so & and = pass through.
Common traps
- Using
.urlQueryAllowedon a single value. An&inside the value then splits the parameter. - Trusting that
+survives. A server may read it as a space; encode it as%2Bwhen it is meant literally.
They might follow up with
- How do I send a path segment safely?
appendingPathComponent(orURL(filePath:)for files), never a string join. - Why does behaviour differ across OS versions?
URL(string:)changed to encode invalid characters; test on your minimum deployment target.
Related questions
- 12.8.2 Are HTTP header names case-sensitive in URLSession, and how do you read them safely?
- 12.8.3 How do you classify what URLSession reports: which failures are URLError, which are not, and which are worth…
- 12.1.2 How do you make a request with URLSession, and why is a 404 not an error?
- 12.3.1 How do you decode and paginate an API response, and which pagination style is safest?
Are HTTP header names case-sensitive in URLSession, and how do you… →