pathlib en lugar de os.path: manejo moderno de rutas

1. Descripción general

Mucho código Python antiguo maneja rutas mediante os.path.join, os.path.splitext, os.listdir… Estas funciones reciben y devuelven cadenas, por lo que el código es largo, difícil de leer y propenso a errores. El módulo pathlib (disponible desde Python 3.4) representa las rutas con objetos Path que tienen todos los métodos necesarios para unir, dividir, leer, escribir y recorrer archivos.

Después de leer este artículo, podrás convertir la mayor parte de tu código os.path a pathlib y conocerás los errores comunes. Los ejemplos usan Python 3.12.

2. Crear objetos Path

Path es el punto de partida para cualquier operación:

from pathlib import Path

reports = Path("data/reports")
home = Path.home()
cwd = Path.cwd()

print(reports)
print(type(reports))
print(home)
data/reports
<class 'pathlib.PosixPath'>
/home/minh

Explicación:

  • Path("data/reports") crea un objeto de ruta. En este momento no hay ninguna operación con el disco. El directorio no necesita existir.
  • En Linux/macOS, Path devuelve PosixPath. En Windows, devuelve WindowsPath. No necesitas preocuparte por la clase específica.
  • Path.home() y Path.cwd() reemplazan a os.path.expanduser("~") y os.getcwd().

Si solo necesitas manipular cadenas de rutas sin tocar archivos reales, puedes usar PurePath, PurePosixPath o PureWindowsPath. Esto es conveniente para procesar rutas de Windows cuando corres en Linux.

3. Unir rutas con el operador /

Este es el cambio más evidente. En lugar de anidar os.path.join, usas el operador /:

import os
from pathlib import Path

# Forma antigua
old = os.path.join("data", "orders", "2026", "order_1001.json")

# Forma nueva
base = Path("data")
new = base / "orders" / "2026" / "order_1001.json"

print(old)
print(new)
print(new == Path(old))
data/orders/2026/order_1001.json
data/orders/2026/order_1001.json
True

El operador / acepta tanto Path como str en el lado derecho. El resultado siempre es un Path, así que puedes seguir concatenando. El separador se ajusta automáticamente según el sistema operativo: / en Linux, \ en Windows.

Si el número de componentes cambia en tiempo de ejecución, usa joinpath:

parts = ["orders", "2026", "order_1001.json"]
path = Path("data").joinpath(*parts)
print(path)  # data/orders/2026/order_1001.json

4. Dividir componentes de la ruta

os.path necesita muchas funciones para esto: basename, dirname, splitext. Con pathlib, todo son atributos:

from pathlib import Path

invoice = Path("/srv/shop/invoices/2026/invoice_042.final.pdf")

print(invoice.name)      # nombre de archivo completo
print(invoice.stem)      # nombre sin la última extensión
print(invoice.suffix)    # última extensión
print(invoice.suffixes)  # todas las extensiones
print(invoice.parent)    # directorio que lo contiene
print(invoice.parents[1])
print(invoice.parts)
invoice_042.final.pdf
invoice_042.final
.pdf
['.final', '.pdf']
/srv/shop/invoices/2026
/srv/shop/invoices
('/', 'srv', 'shop', 'invoices', '2026', 'invoice_042.final.pdf')

Explicación:

  • stem y suffix solo dividen en el último punto. Para archivos .tar.gz, necesitas usar suffixes.
  • parent es equivalente a os.path.dirname. parents es una secuencia de directorios superiores, ascendiendo hacia la raíz.
  • parts divide la ruta en una tupla, útil cuando necesitas comparar cada nivel.

Para crear una ruta nueva con un nombre o extensión diferente, usa los métodos with_*:

invoice = Path("invoices/invoice_042.pdf")

print(invoice.with_suffix(".xml"))    # invoices/invoice_042.xml
print(invoice.with_stem("invoice_043"))  # invoices/invoice_043.pdf
print(invoice.with_name("summary.csv"))  # invoices/summary.csv

Estos métodos no cambian el nombre del archivo en el disco. Solo devuelven un nuevo Path, porque Path es inmutable.

5. Leer y escribir archivos

Para archivos pequeños, no necesitas open():

from pathlib import Path

config = Path("app_config.txt")
config.write_text("currency=VND\ntimeout=30\n", encoding="utf-8")

content = config.read_text(encoding="utf-8")
print(content.splitlines())

logo = Path("logo.bin")
logo.write_bytes(b"\x89PNG")
print(logo.read_bytes())
['currency=VND', 'timeout=30']
b'\x89PNG'
  • write_text abre el archivo, escribe y cierra. El archivo antiguo se sobrescribe.
  • read_text lee todo el contenido como una cadena.
  • write_bytes y read_bytes funcionan de la misma manera con datos binarios.

Para archivos grandes o cuando necesites leer línea por línea, aún puedes usar context manager. Path tiene integrado el método open:

with Path("orders.csv").open("w", encoding="utf-8") as f:
    f.write("id,amount\n1001,250000\n1002,99000\n")

with Path("orders.csv").open(encoding="utf-8") as f:
    total = sum(int(line.split(",")[1]) for line in f if not line.startswith("id"))

print(total)  # 349000

Nota: Siempre pasa encoding="utf-8". Si lo omites, Python usa la codificación predeterminada del sistema. En Windows, es típicamente cp1252, por lo que los datos en vietnamita causarán errores.

6. Verificar y recorrer directorios

6.1. Verificar estado

from pathlib import Path

p = Path("orders.csv")
print(p.exists(), p.is_file(), p.is_dir())
print(p.stat().st_size)
print(p.resolve())
True True False
36
/home/minh/project/orders.csv

resolve() devuelve la ruta absoluta y resuelve symlinks. Reemplaza a os.path.abspath y os.path.realpath.

6.2. Listar y buscar archivos

from pathlib import Path

root = Path("shop")
(root / "orders" / "2026").mkdir(parents=True, exist_ok=True)
(root / "orders" / "2026" / "o1.json").write_text("{}", encoding="utf-8")
(root / "orders" / "o0.json").write_text("{}", encoding="utf-8")
(root / "readme.md").write_text("# Shop", encoding="utf-8")

print(sorted(p.name for p in root.iterdir()))
print(sorted(str(p) for p in root.glob("*.md")))
print(sorted(str(p) for p in root.rglob("*.json")))
['orders', 'readme.md']
['shop/readme.md']
['shop/orders/2026/o1.json', 'shop/orders/o0.json']
  • iterdir() reemplaza a os.listdir(). Devuelve rutas Path completas, no solo nombres de archivos, así que no necesitas concatenarlas de nuevo.
  • glob("*.md") solo busca en el directorio actual.
  • rglob("*.json") busca recursivamente, equivalente a glob("**/*.json").

Desde Python 3.12, Path.walk() reemplaza a os.walk():

for dirpath, dirnames, filenames in Path("shop").walk():
    print(dirpath, filenames)

7. Crear, renombrar y eliminar

import shutil
from pathlib import Path

backup = Path("backup/daily")
backup.mkdir(parents=True, exist_ok=True)

src = Path("orders.csv")
dst = src.rename(backup / "orders_2026-10-17.csv")
print(dst)

dst.unlink()
Path("not_there.txt").unlink(missing_ok=True)

backup.rmdir()
shutil.rmtree("backup")
backup/daily/orders_2026-10-17.csv
  • mkdir(parents=True, exist_ok=True) es equivalente a os.makedirs(..., exist_ok=True).
  • rename devuelve el nuevo Path. En Windows, rename falla si el archivo destino ya existe. Para sobrescribir en cualquier plataforma, usa replace.
  • unlink elimina el archivo. Con missing_ok=True, no falla si el archivo no existe.
  • rmdir solo elimina directorios vacíos. Para eliminar un árbol de directorios completo, necesitas shutil.rmtree. Esta función acepta Path normalmente.

8. Tabla de referencia rápida

os / os.path pathlib
os.path.join(a, b) Path(a) / b
os.path.basename(p) p.name
os.path.dirname(p) p.parent
os.path.splitext(p) p.stem, p.suffix
os.path.exists(p) p.exists()
os.path.isfile(p) / isdir p.is_file() / p.is_dir()
os.path.abspath(p) p.resolve() o p.absolute()
os.path.expanduser("~") Path.home() o p.expanduser()
os.getcwd() Path.cwd()
os.listdir(p) p.iterdir()
glob.glob("**/*.py", recursive=True) p.rglob("*.py")
os.makedirs(p, exist_ok=True) p.mkdir(parents=True, exist_ok=True)
os.remove(p) p.unlink()
os.rename(a, b) a.rename(b)
os.path.getsize(p) p.stat().st_size
os.path.relpath(p, start) p.relative_to(start)

9. Escribir pruebas con pytest

El fixture tmp_path de pytest devuelve directamente un Path, así que el código de prueba que usa pathlib es muy compacto. Supongamos que tienes una función que agrupa archivos PDF de facturas en un directorio de archivo:

# archive.py
from pathlib import Path


def archive_invoices(source: Path, target: Path) -> list[Path]:
    target.mkdir(parents=True, exist_ok=True)
    moved = []
    for pdf in sorted(source.rglob("*.pdf")):
        moved.append(pdf.rename(target / pdf.name))
    return moved

La prueba correspondiente:

# test_archive.py
from pathlib import Path

from archive import archive_invoices


def test_archive_moves_only_pdf_files(tmp_path: Path):
    source = tmp_path / "inbox"
    (source / "2026").mkdir(parents=True)
    (source / "a.pdf").write_bytes(b"%PDF")
    (source / "2026" / "b.pdf").write_bytes(b"%PDF")
    (source / "note.txt").write_text("skip me", encoding="utf-8")
    target = tmp_path / "archive"

    moved = archive_invoices(source, target)

    assert [p.name for p in moved] == ["b.pdf", "a.pdf"]
    assert sorted(p.name for p in target.iterdir()) == ["a.pdf", "b.pdf"]
    assert (source / "note.txt").exists()
    assert not (source / "a.pdf").exists()
pytest -q test_archive.py
.                                                                [100%]
1 passed in 0.02s

El orden en moved es b.pdf antes de a.pdf. La razón: sorted compara objetos Path, e inbox/2026/b.pdf viene antes que inbox/a.pdf porque el carácter 2 es menor que a. Este detalle es fácil de pasar por alto cuando pasas de comparar cadenas con nombres de archivo a comparar objetos Path.

10. Errores comunes

Sumar Path con una cadena. Path("data") + "/x.txt" genera TypeError. Usa / en lugar de +. Si realmente necesitas concatenar al nombre del archivo, usa p.with_name(p.name + "_old").

El lado derecho es una ruta absoluta. El resultado puede sorprenderte:

from pathlib import Path

print(Path("/srv/uploads") / "/etc/passwd")  # /etc/passwd

Un componente absoluto a la derecha reemplaza toda la parte izquierda. Si el nombre del archivo viene del usuario, debes verificarlo antes de usarlo:

base = Path("/srv/uploads").resolve()
candidate = (base / user_input).resolve()
if not candidate.is_relative_to(base):
    raise ValueError("Ruta no válida")

with_suffix sin el punto. p.with_suffix("pdf") genera ValueError. Debes escribir ".pdf".

Olvidar que glob devuelve un generador. Solo puedes iterarlo una vez. Si necesitas usarlo de nuevo o llamar a len(), envuélvelo con list(...). El orden de los resultados tampoco está garantizado, así que necesitas sorted cuando el orden es importante.

Archivos ocultos y stem. Con Path(".env"), suffix es una cadena vacía y stem es ".env". No supongas que todos los archivos tienen una extensión.

Librerías antiguas solo aceptan str. La mayoría de las API estándar aceptan os.PathLike desde Python 3.6. Si encuentras una librería antigua, usa str(p) u os.fspath(p).

Nota: pathlib no reemplaza completamente a os y shutil. Para copiar archivos, aún necesitas shutil.copy2 (Python 3.14 agregará Path.copy). Las variables de entorno se leen a través de os.environ. Los permisos complejos aún necesitan os.chmod u os.access.

¿Cuándo no cambiar? En un bucle que procesa millones de cadenas de ruta sin tocar el disco, crear objetos Path es más lento que manipular cadenas puras. Este es un caso raro, y deberías medir antes de optimizar. Para código antiguo que ya funciona bien, no necesitas cambiarlo todo de una vez. Cambia gradualmente cuando toques cada módulo.

11. Conclusión

pathlib reúne las operaciones dispersas en os, os.path y glob en un único objeto. El código es más corto, se lee más naturalmente y hay menos errores de concatenación de cadenas. Ya has visto cómo unir rutas con /, dividir componentes con name, stem, suffix, parent, leer y escribir archivos con read_text y write_text, recorrer directorios con iterdir, glob, rglob, walk, y cómo escribir pruebas con tmp_path de pytest.

Debes recordar tres puntos clave. Siempre pasa encoding. Ten cuidado al concatenar con rutas absolutas procedentes de entrada externa. Sigue usando shutil para operaciones como copiar y eliminar árboles de directorios. Para código nuevo, usa Path por defecto y vuelve a os.path solo si tienes una razón específica.

pythonpathlibos.pathfile-iopython3