El módulo urllib.parse en Python separa URLs en componentes, reconstruye direcciones, resuelve referencias relativas y codifica datos para query strings. Sustituye concatenaciones frágiles por funciones específicas para esquema, autoridad, path, query y fragmento.
La biblioteca es práctica y compatible con código antiguo, pero no es un validador completo. urlsplit() y urlparse() pueden aceptar entradas extrañas y devolver componentes vacíos en lugar de lanzar errores. Cuando una URL tiene impacto de seguridad, valida esquema, host, puerto, credenciales, path y destino final después del parsing.
Divide una URL
from urllib.parse import urlsplit
resultado = urlsplit(
"https://usuario:secreto@api.ejemplo.com:8443/v1/items"
"?pagina=2&orden=nombre#detalles"
)
print(resultado.scheme)
print(resultado.hostname)
print(resultado.port)
print(resultado.path)
print(resultado.query)
print(resultado.fragment)urlsplit() devuelve scheme, netloc, path, query y fragment. El resultado estructurado también expone username, password, hostname y port.
Prefiere urlsplit para URLs modernas
urlparse() añade un campo histórico llamado params. Para la mayoría de URLs HTTP actuales, urlsplit() es más sencillo. Usa urlparse() solo cuando separar parámetros de path sea una necesidad real.
Las dobles barras cambian la interpretación
from urllib.parse import urlsplit
print(urlsplit("ejemplo.com/pagina"))
print(urlsplit("//ejemplo.com/pagina"))
print(urlsplit("https://ejemplo.com/pagina"))Sin //, la primera cadena se interpreta como path, no como hostname. No añadas un esquema automáticamente sin una regla clara, porque podrías transformar rutas locales, identificadores o esquemas peligrosos.
Valida esquema, hostname y puerto
from urllib.parse import urlsplit
ESQUEMAS = {"https"}
HOSTS = {"api.ejemplo.com", "cdn.ejemplo.com"}
def validar_url(texto: str):
partes = urlsplit(texto)
if partes.scheme.lower() not in ESQUEMAS:
raise ValueError("esquema no permitido")
if partes.hostname not in HOSTS:
raise ValueError("host no permitido")
if partes.username is not None or partes.password is not None:
raise ValueError("no se aceptan credenciales en la URL")
try:
puerto = partes.port
except ValueError as error:
raise ValueError("puerto inválido") from error
if puerto not in (None, 443):
raise ValueError("puerto no permitido")
return parteshostname se normaliza a minúsculas, mientras netloc conserva más del texto original. Leer port puede lanzar ValueError si está fuera de rango.
Parsing no es validación
La documentación oficial advierte que estas funciones priorizan comportamiento práctico. No son validadores estrictos de RFC 3986 ni del estándar WHATWG. Tu aplicación debe definir qué considera válido.
Una política puede exigir HTTPS, host conocido, puerto estándar, ausencia de credenciales, path absoluto, longitud máxima y ausencia de caracteres de control. Otra aplicación puede admitir URLs relativas. La validación debe seguir ese contrato.
URLs con direcciones IP
Cuando el hostname pueda ser un IP literal, valídalo con ipaddress en Python. Esto ayuda a detectar loopback, privados y link-local, pero no resuelve SSRF por sí solo. Un hostname puede devolver varios IPs, cambiar después de la validación o redirigir.
Elimina fragmentos
from urllib.parse import urldefrag
resultado = urldefrag("https://ejemplo.com/manual#instalacion")
print(resultado.url)
print(resultado.fragment)El fragmento normalmente no se envía al servidor HTTP. Eliminarlo puede ayudar en claves de caché y comparaciones, aunque URLs equivalentes todavía pueden diferir en puerto, percent-encoding o normalización del path.
Reconstruye resultados
from urllib.parse import urlsplit
partes = urlsplit("HTTP://Ejemplo.com/pagina?#")
limpia = partes._replace(fragment="").geturl()
print(limpia)_replace() crea un resultado nuevo. geturl() puede convertir el esquema a minúsculas y quitar delimitadores vacíos. No lo uses si necesitas preservar exactamente el texto original.
Resuelve URLs relativas con urljoin
from urllib.parse import urljoin
base = "https://docs.ejemplo.com/guias/python/"
print(urljoin(base, "instalacion.html"))
print(urljoin(base, "../referencia/api.html"))urljoin() implementa las reglas de referencias relativas y resulta útil en crawlers, feeds y documentación.
urljoin puede cambiar el dominio
from urllib.parse import urljoin
base = "https://sitio.ejemplo.com/usuarios/"
print(urljoin(base, "https://atacante.ejemplo/robo"))Una segunda URL absoluta sustituye esquema y hostname. No uses urljoin(base, entrada_externa) esperando permanecer en el dominio original.
from urllib.parse import urljoin, urlsplit
def unir_interno(base: str, relativo: str) -> str:
candidato = urlsplit(relativo)
if candidato.scheme or candidato.netloc:
raise ValueError("URL absoluta no permitida")
resultado = urljoin(base, relativo)
if urlsplit(resultado).hostname != urlsplit(base).hostname:
raise ValueError("el destino salió del host permitido")
return resultadoValida nuevamente la URL final porque la normalización y los redirects añaden decisiones de política.
Codifica componentes con quote
from urllib.parse import quote
nombre = "Informe de ventas/2026"
print(quote(nombre))
print(quote(nombre, safe=""))quote() utiliza percent-encoding. La barra es segura por defecto porque está pensado para paths. Para codificar un único segmento, usa safe="" para impedir que una barra cree otro segmento.
No codifiques una URL completa a ciegas
Esquema, hostname, path, query y fragmento tienen reglas diferentes. Aplicar quote() a toda la URL puede codificar separadores estructurales como :, / y ?. Construye y codifica cada componente.
quote y quote_plus
from urllib.parse import quote, quote_plus
texto = "python avanzado"
print(quote(texto))
print(quote_plus(texto))quote_plus() está pensado para formularios y queries, donde el espacio se convierte en +. Para paths, usa quote(). Un signo más literal debe codificarse para no convertirse en espacio al decodificar.
Crea query strings con urlencode
from urllib.parse import urlencode
parametros = {
"busqueda": "python web",
"pagina": "2",
"idioma": "es",
}
query = urlencode(parametros)
url = f"https://api.ejemplo.com/buscar?{query}"
print(url)urlencode() maneja caracteres especiales. En Python 3.14 está deprecado aceptar algunos objetos falsy distintos de cadenas vacías, bytes y None. Convierte números y valores de dominio explícitamente.
Parámetros repetidos
from urllib.parse import urlencode
query = urlencode(
{"tag": ["python", "web"], "pagina": "1"},
doseq=True,
)
print(query)Sin doseq=True, una lista puede codificarse como representación de Python en lugar de varios pares.
Conserva el orden con pares
from urllib.parse import urlencode
pares = [
("orden", "nombre"),
("orden", "fecha"),
("pagina", "1"),
]
print(urlencode(pares))Una secuencia de pares mantiene orden y claves duplicadas. Puede importar en firmas de solicitudes o APIs con parámetros ordenados.
Interpreta queries con límites
from urllib.parse import parse_qs, parse_qsl
query = "tag=python&tag=web&vacio="
print(parse_qs(query, keep_blank_values=True))
print(parse_qsl(query, keep_blank_values=True))parse_qs() agrupa valores; parse_qsl() conserva la secuencia. Define max_num_fields para entradas externas.
valores = parse_qs(
query_recibida,
keep_blank_values=True,
strict_parsing=True,
max_num_fields=200,
)Errores de decodificación
unquote() usa UTF-8 y reemplaza secuencias inválidas por defecto. Un parser crítico puede usar errors="strict". unquote_to_bytes() devuelve octetos originales cuando la aplicación debe decidir la codificación.
Evita doble decodificación
%252e%252e%252f se convierte en %2e%2e%2f tras una decodificación y en ../ tras dos. Decodifica exactamente una vez en una capa definida. Si el resultado se usa como ruta local, valida path traversal; el mismo riesgo aparece en tarfile en Python.
Texto y bytes
Las funciones aceptan str o bytes ASCII. No mezcles ambos tipos en una llamada. Los bytes no ASCII producen UnicodeDecodeError. Las aplicaciones web suelen beneficiarse de decodificar el transporte explícitamente y trabajar con texto.
IPv6 literal en URLs
Un hostname IPv6 debe usar corchetes, por ejemplo https://[2001:db8::1]:8443/. Los corchetes sin pareja producen error. Valida el hostname con ipaddress y define cómo manejar zone IDs.
Protege los logs
Las URLs pueden contener contraseñas, tokens, datos personales y firmas. No registres el valor completo por defecto. Elimina credenciales y oculta claves sensibles.
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
SENSIBLES = {"token", "password", "key", "signature"}
def url_para_log(url: str) -> str:
partes = urlsplit(url)
query = urlencode([
(clave, "***" if clave.lower() in SENSIBLES else valor)
for clave, valor in parse_qsl(partes.query, keep_blank_values=True)
])
host = partes.hostname or ""
if partes.port:
host = f"{host}:{partes.port}"
return urlunsplit((partes.scheme, host, partes.path, query, ""))Configuración y concurrencia
Carga esquemas, hosts y puertos permitidos mediante una capa validada como configparser en Python. Usa trace en Python para investigar decisiones de parsing y redirects. Los crawlers pueden distribuir URLs con queue en Python y aplicar límites por host.
Pruebas recomendadas
Prueba URLs absolutas y relativas, host ausente, credenciales, puertos inválidos, IPv6, caracteres de control, Unicode, fragmentos, queries duplicadas, demasiados campos, percent-encoding inválido, doble codificación, URL absoluta en urljoin() y redirects hacia redes privadas.
Buenas prácticas
- Prefiere
urlsplit(). - Valida cada componente.
- Usa allowlists para destinos críticos.
- No confíes en
urljoin()con entrada externa. - Codifica cada componente por separado.
- Limita campos de query.
- Evita doble decodificación.
- Oculta secretos en logs.
- Combina validación de URL, DNS e IP contra SSRF.
Conclusión
urllib.parse en Python proporciona herramientas fiables para dividir, resolver, codificar y construir URLs. Un uso seguro requiere una capa adicional de validación basada en el contrato de la aplicación.
Consulta la documentación oficial de urllib.parse y la RFC 3986. El parsing devuelve componentes; confiar en el destino es una decisión separada.







