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,
PathdevuelvePosixPath. En Windows, devuelveWindowsPath. No necesitas preocuparte por la clase específica. Path.home()yPath.cwd()reemplazan aos.path.expanduser("~")yos.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:
stemysuffixsolo dividen en el último punto. Para archivos.tar.gz, necesitas usarsuffixes.parentes equivalente aos.path.dirname.parentses una secuencia de directorios superiores, ascendiendo hacia la raíz.partsdivide 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_textabre el archivo, escribe y cierra. El archivo antiguo se sobrescribe.read_textlee todo el contenido como una cadena.write_bytesyread_bytesfuncionan 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ípicamentecp1252, 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 aos.listdir(). Devuelve rutasPathcompletas, 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 aglob("**/*.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 aos.makedirs(..., exist_ok=True).renamedevuelve el nuevoPath. En Windows,renamefalla si el archivo destino ya existe. Para sobrescribir en cualquier plataforma, usareplace.unlinkelimina el archivo. Conmissing_ok=True, no falla si el archivo no existe.rmdirsolo elimina directorios vacíos. Para eliminar un árbol de directorios completo, necesitasshutil.rmtree. Esta función aceptaPathnormalmente.
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:
pathlibno reemplaza completamente aosyshutil. Para copiar archivos, aún necesitasshutil.copy2(Python 3.14 agregaráPath.copy). Las variables de entorno se leen a través deos.environ. Los permisos complejos aún necesitanos.chmoduos.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.