Article · Apr 28, 2026 · 7 min read

URL encoding in Python

Python ships with everything you need for URL encoding in the standard library — no third-party packages required. The functions live in urllib.parse, and once you know which to use when, URL encoding becomes a two-line problem in Python.

This article covers every function, when to use each, and the gotchas that trip people up.

The five functions you actually need

Out of the dozen URL-related functions in urllib.parse, five do the real work:

from urllib.parse import quote, quote_plus, unquote, unquote_plus, urlencode

quote — RFC 3986 percent-encoding. Space becomes %20. The default for new code.

quote_plus — Form-encoded variant. Space becomes +. Use for query string values when the receiver expects form encoding (most browsers send this for GET form submissions).

unquote — Reverse of quote. Decodes %XX sequences. Does NOT treat + as space.

unquote_plus — Reverse of quote_plus. Decodes %XX AND turns + back into space.

urlencode — Takes a dict (or list of tuples) and builds a complete query string with form encoding.

quote and unquote: the basics

>>> from urllib.parse import quote, unquote
>>> quote('Hello, World!')
'Hello%2C%20World%21'
>>> unquote('Hello%2C%20World%21')
'Hello, World!'

By default, quote leaves forward slash (/) unencoded so you can call it on a path safely:

>>> quote('/api/My Item/details')
'/api/My%20Item/details'

If you need to encode slashes too (because your data legitimately contains them), pass safe='':

>>> quote('/api/My Item/details', safe='')
'%2Fapi%2FMy%20Item%2Fdetails'

The opposite — leaving additional characters unencoded — uses the same parameter:

>>> quote('hello world!', safe='!')
'hello%20world!'

quote_plus: when the receiver expects forms

Most server-side frameworks (Django, Flask, FastAPI, Express, Rails, anything that handles HTML form submissions) accept both + and %20 as space in query strings. But some old APIs only accept +. Match what the receiver expects:

>>> quote_plus('Hello, World!')
'Hello%2C+World%21'

Notice: comma still becomes %2C, but space is now +. That’s the only difference between quote and quote_plus.

urlencode: building query strings from dicts

This is what you reach for 80% of the time. Give it a dict, get a query string:

>>> from urllib.parse import urlencode
>>> urlencode({'q': 'Hello, World!', 'lang': 'en'})
'q=Hello%2C+World%21&lang=en'

If you have repeated keys (an array of values), use a list of tuples instead of a dict:

>>> urlencode([('tag', 'foo'), ('tag', 'bar'), ('tag', 'baz')])
'tag=foo&tag=bar&tag=baz'

Or use the doseq=True flag to expand list values automatically:

>>> urlencode({'tag': ['foo', 'bar', 'baz']}, doseq=True)
'tag=foo&tag=bar&tag=baz'

Working with full URLs: urlparse and urlunparse

When you have a complete URL and need to dissect or rebuild it:

>>> from urllib.parse import urlparse
>>> u = urlparse('https://example.com/search?q=test&page=2#section')
>>> u.scheme
'https'
>>> u.netloc
'example.com'
>>> u.path
'/search'
>>> u.query
'q=test&page=2'
>>> u.fragment
'section'

And to parse the query into a dict:

>>> from urllib.parse import parse_qs, parse_qsl
>>> parse_qs('q=Hello%2C+World%21&lang=en')
{'q': ['Hello, World!'], 'lang': ['en']}
>>> parse_qsl('q=hello&lang=en')
[('q', 'hello'), ('lang', 'en')]

parse_qs returns a dict where every value is a list (because keys can repeat). parse_qsl returns a list of tuples, preserving order.

Real-world patterns

Building a search URL

from urllib.parse import urlencode

base = 'https://api.example.com/search'
params = {
    'q': user_query,
    'lang': 'en',
    'page': 2,
    'limit': 50,
}
url = f'{base}?{urlencode(params)}'

Adding a parameter to an existing URL

from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse

def add_param(url, key, value):
    parsed = urlparse(url)
    params = parse_qsl(parsed.query)
    params.append((key, value))
    new_query = urlencode(params)
    return urlunparse(parsed._replace(query=new_query))

>>> add_param('https://example.com/page?ref=ad', 'utm_source', 'twitter')
'https://example.com/page?ref=ad&utm_source=twitter'

URL-encoding inside f-strings

from urllib.parse import quote
user_input = "café"
url = f"https://api.example.com/search?q={quote(user_input)}"
# 'https://api.example.com/search?q=caf%C3%A9'

Bytes vs strings (and why it matters)

Python 3 distinguishes bytes from strings. The encoding functions accept either:

>>> quote('café')          # str input → assumes UTF-8
'caf%C3%A9'
>>> quote(b'\xc3\xa9')      # bytes input → percent-encodes each byte
'%C3%A9'
>>> quote('café', encoding='latin-1')   # force a different charset
'caf%E9'

For non-UTF-8 sources (legacy systems), pass the explicit encoding parameter.

The requests library handles most of this for you

If you're using the third-party requests library, params encoding is automatic:

import requests
response = requests.get('https://api.example.com/search', params={
    'q': 'Hello, World!',
    'lang': 'en',
})
# requests builds the URL: https://api.example.com/search?q=Hello%2C+World%21&lang=en

requests uses urllib.parse.urlencode under the hood, so the output is identical to building the URL manually.

Common Python gotchas

quote leaves / unencoded by default

This is convenient for paths but wrong for path segment values. If your value might contain a slash, pass safe=''.

>>> quote('A/B Testing')                # wrong for path segment
'A/B%20Testing'
>>> quote('A/B Testing', safe='')       # right
'A%2FB%20Testing'

The urlencode dict ordering changed in Python 3.7

Before 3.7, dict order wasn’t guaranteed, so the query string order was nondeterministic. From 3.7 onward, dicts preserve insertion order and urlencode follows it. If you need a specific order on older Python, pass a list of tuples instead.

Don’t roll your own with %-encoding

I’ve seen code like:

url = base + '?q=' + user_input.replace(' ', '%20')   # wrong

This handles spaces but breaks on every other special character (&, ?, =, #). Always use the standard library.

Quick reference card

Goal Function
Encode a single value (RFC 3986)quote(value)
Encode for query string (form style)quote_plus(value)
Build whole query from dicturlencode(dict)
Decode (no + → space)unquote(value)
Decode (with + → space)unquote_plus(value)
Parse a URLurlparse(url)
Parse query string to dictparse_qs(query)

That’s the entire Python URL encoding toolkit. Reach for urlencode when building, parse_qs when consuming, and quote / unquote when you need single-value control.


Found this useful? Try the URL decoder, the URL encoder, or browse all tools.

More reading

From the blog.