DEV BOX

'26/11/09 UPDATE

WEB / URL / FIELD NOTES

I searched for C++. Why did the server receive 'C '?

A search for C++ reached the server as C followed by two spaces. In a query string, + means a space only to functions that read the form format; to percent-encoding functions such as encodeURIComponent and decodeURIComponent it is just a +. Using real runs on Node.js v24.21.0 and Python 3.9.6, this note shows how to pair the function that writes a query with the one that reads it so that + signs and spaces come back intact.

A user types C++ into a search box, and the front end builds the request address as '/search?q=' + keyword. On the server, the search term comes out as C followed by two spaces. After reading this, you will be able to pick a function that builds the query string and a function that reads it that agree with each other, so that + signs and spaces survive the round trip.

The examples were run on 2026-10-25 on macOS 27.0.1. The JavaScript side used the built-in fetch of Node.js v24.21.0 and a local server built with node:http (http://127.0.0.1:4392). The Python side used urllib.parse in Python 3.9.6, the /usr/bin/python3 on macOS. The search service and the search terms are fictional examples. Browser form submission was not run.

Reproducing it

When a request comes in, the local server logs the address it received and returns the value of q read in two ways. One is new URL(req.url, base).searchParams.get('q'). The other passes the raw text after q= straight to decodeURIComponent. The client put the same search term into the query in three ways.

'?q=' + 'C++'                            // concatenated by hand
'?q=' + encodeURIComponent('C++')
'?' + new URLSearchParams({ q: 'C++' })

The hand-built address went out as q=C++, and the server log shows GET /search?q=C++. Not a character changed on the way. Yet searchParams.get('q') read the value as C and two spaces. Reading the same raw text with decodeURIComponent gave "C++". The addresses built with encodeURIComponent and URLSearchParams, on the other hand, both sent q=C%2B%2B, and both ways of reading it returned "C++". The characters were lost not on the network but at the step where the server read the value.

Node.js v24.21.0 output. Building '?q=' + 'C++' sends q=C++; get() reads "C  " and decodeURIComponent reads "C++". encodeURIComponent('C++') and new URLSearchParams({q:'C++'}) send q=C%2B%2B and both read "C++". new URLSearchParams({q:'a b'}) sends q=a+b; get() reads "a b" and decodeURIComponent reads "a+b". encodeURIComponent('a b') sends q=a%20b and both read "a b". The server log shows GET /search?q=C++, q=C%2B%2B twice, q=a+b and q=a%20b.
Actual output of sending the same search terms to a local server, with the query built three ways. The server read each value with searchParams.get and with decodeURIComponent.

A separate format reads + as a space

The cause is that there is more than one set of rules for reading a query string. In RFC 3986, which defines the generic URI syntax, + is simply one of the reserved characters called sub-delims, alongside !, $, &, = and others. It does not mean a space. In that document, the percent-encoding that stands for the space character is %20.

Writing a space as + belongs to a different format, application/x-www-form-urlencoded. The WHATWG URL Standard gives its parser a step that replaces 0x2B (+) in names and values with 0x20 (space). The same document calls the format the result of many years of implementation accidents and compromises, and notes that it remains in widespread use because HTML forms are so common.

URLSearchParams follows this format both when reading and when writing. In the specification, its constructor parses a string with this format's parser, and turning it back into a string uses this format's serializer. MDN likewise warns that the URLSearchParams constructor interprets plus signs (+) as spaces, and explains that serialization writes a space as +. The searchParams property of a URL object also returns a URLSearchParams object, which is why searchParams.get on the server turned + into spaces.

encodeURIComponent and decodeURIComponent know nothing about this format. According to MDN, encodeURIComponent escapes every character except letters, digits and - _ . ! ~ * ' ( ), so + becomes %2B and a space becomes %20. decodeURIComponent only undoes escapes that start with % and leaves + as it is. That is also why MDN's example for reading query parameters replaces + with a space before calling decodeURIComponent.

The mismatch runs both ways

The first direction is the case above. If the client concatenates a value without encoding it, + goes out as is, and a server that reads with the form format turns it into a space. Not just C++: any value in which + carries meaning meets the same fate. MDN's URLSearchParams page shows a Base64 string whose + signs become spaces, so the original data can no longer be recovered.

The second direction is the reverse. If the client builds the address with new URLSearchParams({ q: 'a b' }), the space becomes + and the query goes out as q=a+b. If the server reads that with decodeURIComponent, what remains is "a+b", not a space. In the run, searchParams.get returned "a b" and decodeURIComponent returned "a+b". The writer encoded by the rules; the reader expected a different format.

One more line is worth a look. The q=a%20b produced by encodeURIComponent('a b') was read as "a b" both ways. %20 and %2B become the same characters whether a form-format parser or decodeURIComponent reads them. If no raw + is left in the text, this mismatch cannot happen.

Python splits the same way

The story is the same when the server is written in Python. urllib.parse has two families of functions side by side. The official documentation describes quote_plus as like quote() but also replacing spaces with plus signs, and unquote_plus as like unquote() but also replacing plus signs with spaces, both as required for HTML form values. urlencode uses quote_plus by default, and parse_qs and parse_qsl read application/x-www-form-urlencoded data.

Run on Python 3.9.6, parse_qs('q=C++') returned C followed by two spaces, the same result as searchParams.get in Node.js. Only parse_qs('q=C%2B%2B'), sent as %2B, gave 'C++'. unquote('C++') left 'C++' as it was, while unquote_plus('C++') produced the two spaces again. On the writing side, quote('a b') produced 'a%20b' and quote_plus('a b') produced 'a+b', and both functions turned + itself into %2B. urlencode({'q': 'a b'}) gave 'q=a+b', and passing quote_via=quote gave 'q=a%20b'.

Python 3.9.6 output. parse_qs('q=C++') gives {'q': ['C  ']} and parse_qs('q=C%2B%2B') gives {'q': ['C++']}. unquote('C++') gives 'C++' and unquote_plus('C++') gives 'C  '. quote('C++') gives 'C%2B%2B', quote('a b') gives 'a%20b', quote_plus('a b') gives 'a+b', quote_plus('C++') gives 'C%2B%2B'. urlencode({'q': 'a b'}) gives 'q=a+b', and with quote_via=quote 'q=a%20b'. unquote('a+b') gives 'a+b'.
Actual output of writing and reading the same values with Python's urllib.parse. Only the form-encoding functions treated + as a space.

Choose the writer and the reader as a pair

The fix is simple. Do not concatenate values into a query string by hand; pass each value through an encoding function. In JavaScript, use one of the two forms below. They are the same ways the client sent its requests in the run above.

const BASE = 'http://127.0.0.1:4392/search';
await fetch(BASE + '?' + new URLSearchParams({ q: keyword }));
await fetch(BASE + '?q=' + encodeURIComponent(keyword));

Match the reader to the writer. Values from a writer that turns spaces into +, such as URLSearchParams or Python's urlencode, must be read with a function that reads + as a space, such as searchParams.get, parse_qs or unquote_plus. Read them with decodeURIComponent or unquote and a + is left where the space was. Values produced by encodeURIComponent or quote, by contrast, use only %20 and %2B, and both reading methods in the run returned the same value for them.

Watch out for two more things. MDN advises never to construct URLSearchParams from a dynamically concatenated string and to use append() instead, because new URLSearchParams('q=' + keyword) reads + as a space just like a hand-built address. MDN also notes that a URL object's search encodes spaces as %20 while searchParams encodes them as +, so updating searchParams once can change how the address looks even when the values are the same.

DEV NOTES

How each function writes a space and reads +

Form-encoding functions read + as a space; percent-decoding alone leaves + as it is

  • URLSearchParams (JavaScript)

    q=a+b → get('q') = 'a b'

    • Writes: space → +, + → %2B
    • Reads: + → space
  • Python form functions

    urlencode({'q': 'a b'}) → 'q=a+b'

    • quote_plus, urlencode
    • → write a space as +
    • parse_qs, unquote_plus
    • → read + as a space
  • encodeURIComponent · decodeURIComponent

    decodeURIComponent('a+b') → 'a+b'

    • Writes: space → %20, + → %2B
    • Reads: + stays +
  • Python quote · unquote

    unquote('C++') → 'C++'

    • Writes: space → %20, + → %2B
    • Reads: + stays +

Checked by running on Node.js v24.21.0 and Python 3.9.6 on 2026-10-25. What separates them is whether the reader turns + into a space.

Pick the writer and the reader from the same box and both + and spaces come back intact. Mix boxes and one of them changes.

If + signs have vanished from a search term, look first at the raw text in the server log. If + is still there, as in q=C++, the sender did not encode the value. If q=a+b was read with decodeURIComponent and a + remained, the reader did not know about the form format. There is one line to remember: + means a space only to a form-encoding parser.

THE END
RSS feedBack to the library
COMMENTS BOX

Add your perspective.

Share a question, another approach, or something you have tried.

Newest first

Checking sign-in…

Loading comments…