Codificación URL: cómo funciona el percent-encoding

La codificación URL sustituye cada byte que no puede aparecer tal cual en una dirección web por un signo % seguido del valor de ese byte en hexadecimal.

Una URL solo puede contener un pequeño conjunto de caracteres ASCII, y varios de ellos tienen una función concreta: / separa los segmentos de la ruta, ? inicia la query string, & separa los parámetros y # marca el fragmento. En cuanto un dato contiene un espacio, una letra con tilde o uno de esos caracteres, hay que codificarlo. La codificación URL, también llamada codificación por porcentaje o percent-encoding, está definida en el RFC 3986. Lo que en realidad le dice al servidor es: «este carácter forma parte del valor, no es un separador».

El principio: un byte se convierte en tres caracteres

Un carácter codificado se escribe como % seguido de dos dígitos hexadecimales que indican el valor de un byte (RFC 3986, sección 2.1). El espacio tiene el código 32, que en hexadecimal es 20, así que se convierte en %20. Codificar cualquier texto lleva tres pasos:

  1. Convertir el texto en bytes UTF-8.
  2. Dejar tal cual los bytes que representan caracteres no reservados (letras ASCII, dígitos y - . _ ~).
  3. Sustituir cada uno de los demás bytes por % y su valor hexadecimal, preferiblemente en mayúsculas.

Un carácter fuera de ASCII ocupa varios bytes en UTF-8, y por eso una sola letra puede convertirse en varios grupos con porcentaje:

CarácterBytes UTF-8Forma codificada
espacio20%20 (o + en un formulario)
&26%26
=3D%3D
+2B%2B
éC3 A9%C3%A9
€E2 82 AC%E2%82%AC
😀F0 9F 98 80%F0%9F%98%80

Así, «café & thé» se convierte en caf%C3%A9%20%26%20th%C3%A9. UTF-8 no es una elección arbitraria: lo recomienda el RFC 3986 (sección 2.5) y es lo que usan los navegadores y los lenguajes modernos. Algunos sistemas antiguos codificaban el texto en Latin-1, donde é es un único byte y se convierte en %E9. Un decodificador UTF-8 rechaza esa secuencia: en JavaScript, decodeURIComponent('%E9') lanza URIError: URI malformed (URI mal formada).

Caracteres reservados y no reservados

El RFC 3986 divide los caracteres en dos grupos:

  • No reservados (sección 2.3): A-Z a-z 0-9 - . _ ~. Nunca hace falta codificarlos, y el estándar pide no hacerlo: %41 y A significan lo mismo.
  • Reservados (sección 2.2): los delimitadores generales : / ? # [ ] @ y los subdelimitadores ! $ & ' ( ) * + , ; =. Son los que dan estructura a la URL.

La regla cabe en una frase: un carácter reservado se escribe tal cual cuando actúa como separador, y se codifica cuando forma parte de los datos. Compare:

https://example.com/search?q=pan&leche      -> q = "pan", más un parámetro vacío "leche"
https://example.com/search?q=pan%26leche    -> q = "pan&leche"

Todos los demás caracteres, incluidos los espacios, las comillas, el propio %, las letras con tilde y los emojis, deben codificarse dondequiera que aparezcan. Por eso el signo % se escribe %25: «100 %» con un espacio se convierte en 100%20%25.

El espacio: ¿%20 o +?

Según el RFC 3986, un espacio se codifica como %20. Los formularios HTML, sin embargo, usan otro formato, application/x-www-form-urlencoded, en el que un espacio se convierte en + y un signo más auténtico tiene que escribirse %2B. Los navegadores lo usan en los formularios enviados con GET, y la mayoría de los servidores decodifican así las query strings.

Eso provoca una confusión habitual. En una ruta, + es simplemente un signo más. En una query string se suele leer como un espacio: una búsqueda de «C++» enviada sin codificar, ?q=C++, llega al servidor como «C» seguida de dos espacios. Bien codificada, es ?q=C%2B%2B.

LenguajeEspacio como %20 (RFC 3986)Espacio como + (formularios)
JavaScriptencodeURIComponent()URLSearchParams
PHPrawurlencode()urlencode(), http_build_query()
Pythonurllib.parse.quote()quote_plus(), urlencode()

Las dos formas son correctas en una query string, siempre que el lado que decodifica siga la misma convención que el lado que codifica.

Codificar en JavaScript: qué función usar

JavaScript tiene dos funciones integradas que se diferencian en los caracteres que dejan intactos. Para un valor suelto, ya sea un parámetro de la query o un segmento de la ruta, use encodeURIComponent: codifica todo excepto A-Z a-z 0-9 - _ . ! ~ * ' ( ). encodeURI da por hecho que recibe una URL completa cuya estructura ya es correcta, así que no toca separadores como &, / y ?, aunque formen parte de sus datos. La lista exacta de caracteres que omite cada función, y los casos en los que encodeURI sigue siendo la herramienta adecuada, se explican en encodeURI vs encodeURIComponent.

Cuando construye una URL a partir de sus partes, lo más seguro es dejar que la API URL codifique cada parte en su sitio:

const url = new URL('https://example.com/search');
url.searchParams.set('q', 'café & thé');
url.searchParams.set('sort', 'price');
url.href;  // 'https://example.com/search?q=caf%C3%A9+%26+th%C3%A9&sort=price'

Tenga en cuenta que searchParams sigue la convención de los formularios, así que los espacios salen como +.

Ejemplos en JavaScript, PHP y Python

Este es el mismo valor codificado y decodificado en los tres lenguajes, con las dos convenciones para el espacio (en PHP, con el archivo guardado en UTF-8):

// JavaScript
encodeURIComponent('café & thé');                 // 'caf%C3%A9%20%26%20th%C3%A9'
new URLSearchParams({ q: 'café & thé' }).toString(); // 'q=caf%C3%A9+%26+th%C3%A9'
decodeURIComponent('caf%C3%A9%20%26%20th%C3%A9'); // 'café & thé'

// PHP (file saved as UTF-8)
rawurlencode('café & thé');                // caf%C3%A9%20%26%20th%C3%A9
urlencode('café & thé');                   // caf%C3%A9+%26+th%C3%A9
http_build_query(['q' => 'café & thé']);   // q=caf%C3%A9+%26+th%C3%A9
urldecode('caf%C3%A9+%26+th%C3%A9');       // café & thé

# Python
from urllib.parse import quote, quote_plus, urlencode, unquote_plus
quote('café & thé')                    # 'caf%C3%A9%20%26%20th%C3%A9'
quote_plus('café & thé')               # 'caf%C3%A9+%26+th%C3%A9'
urlencode({'q': 'café & thé'})         # 'q=caf%C3%A9+%26+th%C3%A9'
unquote_plus('caf%C3%A9+%26+th%C3%A9') # 'café & thé'

Hay dos detalles que conviene conocer. En Python, quote() no toca la barra por defecto: quote('a/b') devuelve a/b. Para codificar un valor suelto, pase safe='', que da a%2Fb. En PHP, urlencode() codifica además la virgulilla como %7E, mientras que rawurlencode() la deja tal cual, como exige el RFC 3986.

Doble codificación

Codificar una cadena que ya está codificada convierte cada % en %25. %20 pasa a ser %2520, y caf%C3%A9 pasa a ser caf%25C3%25A9. Con la convención de los formularios, lo que delata el problema es el +: en PHP, urlencode(urlencode('a b')) devuelve a%2Bb, que el servidor decodifica como «a+b» en lugar de «a b».

La causa es casi siempre la misma: dos capas de código codifican el mismo valor, por ejemplo una biblioteca HTTP que codifica parámetros que usted ya había codificado. La misma trampa existe al decodificar: en PHP, $_GET ya contiene valores decodificados, y llamar a urldecode() sobre ellos los decodifica por segunda vez, lo que, entre otras cosas, convierte en espacios signos + legítimos. La regla: codifique el valor original una sola vez, en el momento en que entra en la URL, y decodifíquelo una sola vez al leerlo.

Solo hay un caso que justifica algo que parece doble codificación: una URL que se pasa como parámetro de otra URL, por ejemplo en redirect=. Si la URL interior ya contiene %20, su forma codificada contiene %2520, y eso es correcto: el receptor decodifica una vez y recupera la URL original.

Codificar y decodificar online

Para comprobar un valor sin escribir código, el codificador de URL ofrece los dos modos, encodeURIComponent y encodeURI, para que compare sus resultados con su propio texto. Decodificar URL decodifica a medida que escribe y lee + como un espacio, igual que los formularios, así que ahí un signo más auténtico tiene que introducirse como %2B. Todo ocurre en su navegador.

Preguntas Frecuentes

¿Por qué un espacio se convierte en %20 en una URL?

El código del carácter espacio es 32 en decimal, que es 20 en hexadecimal. La codificación URL escribe un byte como % seguido de sus dos dígitos hexadecimales; de ahí %20.

¿Hay que codificar las letras con tilde en una URL?

Sí. Una letra como é se convierte en bytes UTF-8 y después se codifica cada byte: é se convierte en %C3%A9. Los navegadores suelen mostrar la forma decodificada en la barra de direcciones, pero la petición contiene la forma codificada.

¿Qué diferencia hay entre %20 y +?

%20 es la codificación estándar de un espacio según el RFC 3986. El + significa espacio solo en los datos de formulario, es decir, en la práctica, en las query strings; en una ruta es un signo más.

¿Qué caracteres no hace falta codificar nunca?

Los caracteres no reservados del RFC 3986: las letras ASCII sin tilde, los dígitos, el guion, el punto, el guion bajo y la virgulilla. Todos los demás caracteres deben codificarse cuando forman parte de los datos.