Cuando escribes import requests, Python no adivina dónde está ese módulo. Recorre una lista de directorios en orden, comprueba si existe algo que coincida con el nombre, y devuelve el primero que encuentra. Esa lista es sys.path, y entenderla a fondo te evita horas de ModuleNotFoundError desconcertantes y decisiones de diseño que luego hay que deshacer.
sys.path es simplemente una lista de strings donde cada elemento es una ruta absoluta a un directorio (o a un archivo ZIP). Python la consulta de arriba a abajo en cada import. Cuando encuentra una coincidencia, para. Si llega al final sin encontrar nada, lanza ModuleNotFoundError. La prioridad importa: si dos entornos tienen versiones distintas del mismo paquete, gana el que aparece primero en la lista.
La lista no se construye de forma arbitraria. Python la ensambla siguiendo un orden definido en el arranque del intérprete:
- El directorio del script que lanzaste (o
''—string vacío— si ejecutas el intérprete interactivo, que representa el directorio de trabajo actual). - Los directorios definidos en la variable de entorno
PYTHONPATH. - Los directorios de la instalación estándar de Python (stdlib y dependencias del sistema).
- Los
site-packagesdel entorno virtual activo (o del sistema si no hay entorno virtual).
Este orden tiene implicaciones reales: un archivo random.py en tu directorio de trabajo shadea al módulo random de la stdlib. Es uno de los errores más silenciosos que existen.
Modificar sys.path en tiempo de ejecución (sys.path.insert(0, '/algún/path')) es técnicamente posible, pero en producción es casi siempre la señal de que algo está mal estructurado. El problema no es el hack en sí, sino lo que introduce: el comportamiento del import depende de en qué orden se ejecutan los módulos, los tests se comportan diferente según desde dónde los lances, y cualquier herramienta de análisis estático queda ciega a esa dependencia implícita. La solución correcta casi siempre es un pyproject.toml con pip install -e . o restructurar el paquete.
Para añadir paths de forma persistente sin tocar código, existe un mecanismo más limpio: los archivos .pth. Un archivo con extensión .pth colocado en el directorio site-packages del entorno puede contener una ruta por línea, y Python las añade automáticamente a sys.path durante la inicialización del sitio. Es el mecanismo que usan herramientas como pip install -e . por debajo.
Cuando activas un entorno virtual, el script de activación no hace magia: simplemente ajusta PATH para que el binario python que encuentres primero sea el del entorno, y ese binario tiene hardcodeada su ubicación de site-packages. Al arrancar, Python detecta que está dentro de un entorno virtual, descarta los site-packages del sistema (a menos que hayas usado --system-site-packages al crear el entorno) y apunta a los del entorno. Todo sin que toques sys.path manualmente.
# inspect_path.py
import sys
import site
from pathlib import Path
def show_path_sources() -> None:
print("=== sys.path actual ===")
for i, p in enumerate(sys.path):
label = ""
if p == "":
label = " ← directorio de trabajo (entrada vacía)"
elif "site-packages" in p:
label = " ← site-packages"
elif str(Path(sys.prefix) / "lib") in p:
label = " ← stdlib"
print(f" [{i}] {p!r}{label}")
print("\n=== site-packages disponibles ===")
for sp in site.getsitepackages():
pth_files = list(Path(sp).glob("*.pth"))
print(f" {sp}")
for pth in pth_files:
print(f" └─ {pth.name}")
for line in pth.read_text().splitlines():
line = line.strip()
# Las líneas que empiezan con 'import' son código ejecutable,
# no paths; las ignoramos para este diagnóstico.
if line and not line.startswith("#") and not line.startswith("import"):
print(f" → añade: {line!r}")
print("\n=== ¿Entorno virtual activo? ===")
# sys.base_prefix es el Python del sistema; si difiere de sys.prefix,
# estamos dentro de un venv.
in_venv = sys.prefix != sys.base_prefix
print(f" {'Sí' if in_venv else 'No'}")
if in_venv:
print(f" prefix del venv : {sys.prefix}")
print(f" prefix del sistema: {sys.base_prefix}")
if __name__ == "__main__":
show_path_sources()
Qué revela cada pieza
sys.path es una lista mutable de strings, no objetos Path. Por eso la comparación "" in p funciona y Path("") in sys.path no. El string vacío "" en la posición cero es sutil: no es lo mismo que ".". El string vacío se resuelve contra el directorio de trabajo en el momento del import, lo que significa que si cambias de directorio con os.chdir() entre imports, Python buscará en sitios diferentes. Eso es suficiente para crear bugs intermitentes difíciles de reproducir.
La comparación sys.prefix != sys.base_prefix es la forma canónica de detectar un entorno virtual desde código. sys.prefix apunta a la raíz del entorno activo; sys.base_prefix siempre apunta al Python del sistema, y ninguno de los dos cambia si modificas sys.path manualmente, lo que confirma que activar un venv no es un hack de la lista —es una propiedad del intérprete mismo.
site.getsitepackages() devuelve los directorios donde pip instala paquetes en el entorno actual. Los archivos .pth que encuentras ahí son el rastro que dejan las instalaciones en modo editable (pip install -e): en lugar de copiar el código, añaden el directorio fuente al path para que los cambios en el código se reflejen sin reinstalar.
La iteración sobre .pth que hace el script ignora las líneas que empiezan con import porque .pth admite una característica poco documentada: si una línea comienza con import, Python la ejecuta como código durante la inicialización del sitio. Es el mecanismo que usan algunas herramientas de cobertura para instalarse como hooks. No necesitas conocerlo para el día a día, pero saber que existe explica por qué procesar .pth no es tan simple como leer líneas de texto.
Errores que debes conocer
Error: Shadowing involuntario de módulos de stdlib. Si creas un archivo llamado email.py, json.py o cualquier otro nombre que coincida con un módulo estándar en tu directorio de trabajo, Python lo importará a él primero porque "" está al inicio de sys.path.
# ❌ Wrong
# Archivo: json.py en el directorio raíz del proyecto
def loads(s):
return {"hacked": True}
# En otro módulo del mismo proyecto:
import json # importa TU json.py, no el de stdlib
json.loads('{}') # funciona "raro" o explota
# ✅ Right # Renombra el archivo a algo que no colisione con stdlib: # json_utils.py, serializers.py, etc. import json # ahora sí apunta a stdlib
Renombrar el archivo es la única solución real; importlib y otras técnicas de importación explícita no te salvan si el módulo equivocado ya fue cargado en sys.modules.
Error: Insertar rutas en sys.path dentro de código de producción para resolver imports rotos.
# ❌ Wrong # Al inicio de src/app/main.py para poder importar módulos hermanos: import sys sys.path.insert(0, "/home/user/myproject/src") from utils import helpers # ahora "funciona"
# ✅ Right # pyproject.toml con la estructura de paquete correcta: # [project] # name = "myproject" # ... # # Instalado con: # pip install -e . # # Y entonces simplemente: from myproject.utils import helpers
El sys.path.insert codifica una ruta absoluta que sólo existe en tu máquina y rompe cualquier despliegue en otra ruta. La instalación editable delega la responsabilidad de las rutas a las herramientas, donde pertenece.
Error: Asumir que PYTHONPATH del sistema host se hereda dentro de un entorno virtual.
# ❌ Wrong # El desarrollador tiene en su .bashrc: # export PYTHONPATH=/opt/legacy_libs # # Activa el venv y asume que /opt/legacy_libs NO está disponible. # Pero sí lo está, porque PYTHONPATH se procesa antes que site-packages. import legacy_module # importa, aunque no está instalado en el venv
# ✅ Right # Limpia PYTHONPATH antes de trabajar con el venv, o usa: # python -E script.py # ignora PYTHONPATH y otras variables de entorno # En CI/CD, nunca dependas de PYTHONPATH del entorno del agente.
PYTHONPATH no respeta los límites del entorno virtual; es una variable de entorno del sistema operativo que el intérprete lee independientemente. Si tus dependencias son parte de un proyecto, instálalas en el venv.
Control Block
N° 64