Fix: URL Percent Encoding Does Not Escape Slashes in Swift

Fix: URL Percent Encoding Does Not Escape Slashes in Swift

Written by

in

Quick answer: Standard URL character sets like .urlQueryAllowed do not encode slashes (/) because slashes are valid query delimiters under RFC 3986. To escape slashes and other reserved characters safely, assemble your request using URLComponents and URLQueryItem, or apply addingPercentEncoding(withAllowedCharacters:) using .urlHostAllowed or a custom CharacterSet.

Why this happens

When encoding strings to pass as URL parameters or path segments, developers often find that characters like /, &, or = remain unencoded. In legacy Objective-C and early Swift code, calling methods like stringByAddingPercentEscapesUsingEncoding left slashes untouched. In modern Swift, calling addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) produces the same result: a string like "folder/item" remains "folder/item" rather than becoming "folder%2Fitem".

This behavior occurs because Foundation’s predefined CharacterSet instances are based on RFC 3986 section specifications. Under RFC 3986, the forward slash (/) and question mark (?) are allowed characters within a query component. The system treats .urlQueryAllowed as the set of all characters that may legally appear anywhere inside the entire query string, not the set of characters allowed within an individual key or value.

When you interpolate a raw string containing / directly into a query string or path segment, HTTP servers and parsers treat the slash as a sub-path delimiter or structural separator rather than literal text. If your parameter values contain slashes, spaces, or symbols, you must either let Foundation construct the query using dedicated component types or explicitly remove structural delimiters from your allowed character set.

How to fix it

1. Construct URLs with URLComponents and URLQueryItem (Recommended)

The safest way to build URLs with parameters containing slashes, spaces, or symbols is to use URLComponents together with URLQueryItem. When you assign items to queryItems, Foundation automatically handles the percent-encoding for each key and value, ensuring structural characters do not break the URL structure.

import Foundation

func makeSearchURL(searchTerm: String) -> URL?
    var components = URLComponents()
    components.scheme = "https"
    components.host = "api.example.com"
    components.path = "/v1/search"

    // URLQueryItem automatically percent-encodes values, escaping slashes and spaces
    components.queryItems = [
        URLQueryItem(name: "filter", value: searchTerm),
        URLQueryItem(name: "format", value: "json")
    ]

    return components.url
}

if let url = makeSearchURL(searchTerm: "categories/electronics") {
    print(url.absoluteString)
    // Prints: https://api.example.com/v1/search?filter=categories%2Felectronics&format=json
}

Using URLComponents eliminates manual string formatting errors and prevents characters like & and = in user input from corrupting your query parameters.

2. Use addingPercentEncoding with .urlHostAllowed

If you need to encode a standalone string where slashes must be converted to %2F, you can use the .urlHostAllowed character set. In RFC 3986, hostnames cannot contain forward slashes, so .urlHostAllowed does not include /.

import Foundation

let originalString = "reports/2026/q1"

if let encodedString = originalString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) {
    print(encodedString)
    // Prints: reports%2F2026%2Fq1
}

While .urlHostAllowed escapes slashes, it also excludes characters like :, @, and ?. Verify that this set fits your endpoint’s expectations before applying it broadly.

3. Create a custom CharacterSet for strict parameter encoding

Predefined sets such as .urlQueryAllowed leave characters like /, +, &, and = unencoded. When formatting query parameters manually or interfacing with strict backend APIs, define a custom set by removing reserved characters from .urlQueryAllowed:

import Foundation

extension CharacterSet {
    /// A character set conforming to RFC 3986 for individual query values.
    static let urlQueryValueAllowed: CharacterSet = {
        // Start with the standard query set
        var set = CharacterSet.urlQueryAllowed
        // Remove sub-delimiters and reserved characters that break key=value parsing
        set.remove(charactersIn: "!*'();:@&=+$,/?%#[]")
        return set
    }()
}

let rawParam = "user/data=true&active=yes"

if let escapedParam = rawParam.addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed) {
    print(escapedParam)
    // Prints: user%2Fdata%3Dtrue%26active%3Dyes
}

This approach ensures that your manual string interpolations will not corrupt URL structure when sending data through custom protocols or web sockets.

4. Encode path components using appendingPathComponent

If the slash represents part of a resource filename rather than a hierarchy, avoid concatenating it with string operators (+). Instead, pass the raw string to Foundation’s path manipulation methods:

import Foundation

let baseURL = URL(string: "https://api.example.com/files")!
let resourceName = "document/archive.pdf"

// In modern Swift (iOS 16+ / macOS 13+):
let modernURL = baseURL.appending(component: resourceName)
print(modernURL.absoluteString)
// Prints: https://api.example.com/files/document%2Farchive.pdf

// For backwards compatibility across older OS releases:
let legacyURL = baseURL.appendingPathComponent(resourceName)
print(legacyURL.absoluteString)
// Prints: https://api.example.com/files/document%2Farchive.pdf

Foundation’s appending(component:) method automatically escapes forward slashes because it treats the argument as a single component rather than a sub-path.

How to prevent it

  • Never build query strings with String interpolation: Avoid writing "\(baseURL)?query=\(param)". Use URLComponents and URLQueryItem to delegate encoding to the OS.
  • Differentiate path components from sub-paths: Use baseURL.appending(path: ...) if your string contains intentional directory separators, and baseURL.appending(component: ...) if slashes are literal text.
  • Audit predefined character sets: Remember that .urlQueryAllowed, .urlPathAllowed, and .urlFragmentAllowed are intended for entire components, not individual key-value pairs.
  • Verify round-trip decoding: Add unit tests that decode encoded URLs using removingPercentEncoding to ensure strings return to their original form without data loss.

FAQ

Why doesn’t .urlQueryAllowed encode the ampersand (&) or equal sign (=)?

According to RFC 3986, & and = are valid syntax characters within a query component used to separate multiple key-value pairs. Because .urlQueryAllowed defines characters allowed anywhere in the query string as a whole, it treats them as safe and does not encode them.

What is the difference between appending(path:) and appending(component:)?

appending(path:) parses forward slashes as structural directory separators, adding multiple path segments to the URL. In contrast, appending(component:) treats the entire argument as a single path segment and percent-encodes any slashes inside it into %2F.

How do I decode a percent-encoded string back into standard text?

Call the removingPercentEncoding property on your String. This property returns an optional String? containing the decoded characters, or nil if the string contains an invalid or malformed percent-escape sequence.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 3.0; this article is shared under the same license.