Se hai imparato Python prima del 2015 circa, ogni script che toccava il filesystem iniziava con import os e costruiva i percorsi incollando stringhe insieme con os.path.join. Funzionava. Funziona ancora. Ma ogni riga di quel codice è una stringa. Il percorso non sa di essere un percorso. Lo puoi passare ovunque ci si aspetti una stringa, anche in posti che non hanno nulla a che fare con un percorso. La gestione cross-platform dei separatori è una chiamata di funzione. Leggere un file è open(os.path.join(base, "data", "events.json"), "r", encoding="utf-8").read(): otto token di cerimonia per una sola operazione.
pathlib è nella standard library da Python 3.4, e a partire dalla 3.13 le ruvidezze sono sparite. Gli oggetti Path sono il modo moderno. Questa lezione è il piccolo insieme di metodi che copre quasi tutto quello che farai con i file in uno script vero.
Le basi: cos’è un Path
from pathlib import Path
p: Path = Path("/tmp/foo.txt") # POSIX assoluto
q: Path = Path("data/raw/events.json") # relativo, agnostico al sistema operativo
home: Path = Path.home() # /home/narcis o C:\Users\narcis
here: Path = Path.cwd() # directory di lavoro corrente
Path è una classe. Su Linux ottieni un PosixPath, su Windows un WindowsPath. Di solito non te ne importa; entrambi condividono la stessa interfaccia. Il costruttore accetta stringhe, altri oggetti Path, e qualunque cosa implementi __fspath__(). Non tocca il filesystem. Creare un Path non verifica che il file esista, non è I/O, non solleva eccezioni.
Unire percorsi: l’operatore /
Questa è la feature di punta.
base: Path = Path("data")
events: Path = base / "raw" / "events.json"
# Path('data/raw/events.json') su POSIX
# WindowsPath('data\\raw\\events.json') su Windows
L’operatore / unisce i segmenti del percorso con il separatore appropriato per il sistema operativo. Niente più os.path.join(a, b, c). Niente più domande su cosa succede se uno dei segmenti ha uno slash finale. Il lato destro può essere una stringa o un altro Path.
Tre cose da sapere:
- Se un segmento è un percorso assoluto, l’unione si resetta a quel percorso assoluto.
Path("a") / "/b"èPath("/b"). Questo a volte morde; se stai concatenando input dell’utente, fai attenzione. /funziona in entrambe le direzioni purché un lato sia unPath.Path("a") / "b"e"a" / Path("b")funzionano entrambi.- Non c’è l’operatore
+. Usa/.
Le proprietà che userai di continuo
p: Path = Path("/var/log/app/access.log.gz")
p.name # 'access.log.gz' -- componente finale
p.stem # 'access.log' -- nome meno l'ultimo suffisso
p.suffix # '.gz' -- solo l'ultima estensione
p.suffixes # ['.log', '.gz'] -- tutte
p.parent # Path('/var/log/app')
p.parents[1] # Path('/var/log') -- risali la catena
p.parts # ('/', 'var', 'log', 'app', 'access.log.gz')
p.is_absolute() # True
stem e suffix sono i due a cui ricorrerai nell’80% degli script di file processing. Vuoi cambiare estensione?
csv: Path = Path("report.xlsx").with_suffix(".csv")
# Path('report.csv')
Vuoi rinominare il file ma tenere l’estensione?
renamed: Path = p.with_name("error.log.gz")
renamed = p.with_stem("error") # 3.9+: cambia solo lo stem, mantieni il suffisso
Questi restituiscono nuovi oggetti Path. L’originale rimane invariato. Path è immutabile, come una stringa.
Controlli di esistenza e tipo
p: Path = Path("data/raw/events.json")
p.exists() # True se c'è qualcosa a quel percorso
p.is_file() # True se è un file regolare
p.is_dir() # True se è una directory
p.is_symlink()
Questi colpiscono il filesystem. Seguono i symlink di default. Restituiscono False (invece di sollevare eccezioni) se il percorso non esiste, che di solito è quello che vuoi per una guard.
config: Path = Path("config.toml")
if not config.is_file():
raise SystemExit(f"Configurazione mancante: {config}")
Lettura e scrittura
I metodi di convenienza che sostituiscono quattro righe di boilerplate con open():
text: str = Path("notes.md").read_text(encoding="utf-8")
data: bytes = Path("image.png").read_bytes()
Path("output.txt").write_text("hello\n", encoding="utf-8")
Path("output.bin").write_bytes(b"\x00\x01\x02")
Ogni read_text dovrebbe specificare encoding="utf-8" esplicitamente. Il default dipende dal locale negli interpreti più vecchi e ha causato abbastanza grattacapi cross-platform che la 3.10 ha aggiunto un warning, la 3.15 renderà UTF-8 il default del locale, ma sii esplicito comunque. Il te del futuro ringrazierà il te del presente.
Per file grandi, non usare read_text: carica tutto in memoria. Usa open():
with Path("huge.log").open("r", encoding="utf-8") as f:
for line in f:
process(line)
Path.open() funziona esattamente come la open() built-in, solo con il percorso già incorporato. Il context manager chiude il file. Lo streaming attraverso un file grande usa memoria costante.
Globbing: trovare file con un pattern
data_dir: Path = Path("data")
# File solo in questa directory
csvs: list[Path] = list(data_dir.glob("*.csv"))
# Ricorsivo: ogni file Python sotto src/
py_files: list[Path] = list(Path("src").rglob("*.py"))
# Come rglob ma con il wildcard ** esplicito
also_py: list[Path] = list(Path("src").glob("**/*.py"))
glob restituisce un generator, non una lista. Avvolgilo con list() se devi contare o riutilizzare. Itera direttamente se stai processando ognuno e non ti importa del totale.
for log in Path("/var/log").rglob("*.log"):
if log.stat().st_size > 1_000_000:
print(f"grosso: {log}")
rglob ricorre; glob no. Il wildcard ** in glob("**/*.py") fa la stessa cosa di rglob("*.py"): scegli quello che si legge meglio per te.
Elencare una directory
for child in Path("data").iterdir():
print(child.name, "è dir" if child.is_dir() else "è file")
iterdir() restituisce oggetti Path per tutto ciò che c’è in una directory: file, sottodirectory, symlink, tutto. Nessuna garanzia sull’ordine; ordina se hai bisogno di consistenza.
sorted_files: list[Path] = sorted(Path("data").iterdir())
Creare directory
out: Path = Path("artifacts/2026/05")
out.mkdir(parents=True, exist_ok=True)
I due argomenti che passerai sempre:
parents=True: crea le directory intermedie. Senza questo, mkdir fallisce se un genitore non esiste (come la vecchia distinzione tramkdiremkdir -p).exist_ok=True: non sollevare eccezioni se la directory esiste già. Senza questo, mkdir fallisce alla seconda esecuzione del tuo script.
Insieme: creazione di directory idempotente e ricorsiva. Imposta entrambi. Vai avanti.
Cancellare cose
Path("temp.txt").unlink() # cancella un file
Path("temp.txt").unlink(missing_ok=True) # non sollevare se è già sparito
Path("empty_dir").rmdir() # cancella una directory vuota
import shutil
shutil.rmtree(Path("build")) # cancella una directory e tutto al suo interno
shutil.rmtree è l’unica operazione comune sui file che non ha un metodo di Path, perché è abbastanza distruttiva che l’import esplicito è una feature, non un bug. Accetta direttamente un Path.
Risolvere e confrontare percorsi
Due percorsi possono riferirsi allo stesso file ma sembrare diversi come stringhe. data/raw/../raw/events.json e data/raw/events.json sono lo stesso. Così come lo sono data/raw/events.json e il percorso assoluto che lo contiene. Per confrontare in modo affidabile, normalizza prima.
p: Path = Path("data/raw/../raw/events.json")
p.resolve()
# Path('/home/narcis/project/data/raw/events.json'): assoluto, symlink seguiti, .. collassati
resolve() rende un percorso assoluto, segue i symlink, e rimuove i segmenti . e ... Tocca il filesystem. Usalo prima di salvare un percorso da qualche parte o di confrontarlo con un altro. Due percorsi che sembrano diversi ma si riferiscono allo stesso file risulteranno uguali dopo resolve():
a: Path = Path("notes.md").resolve()
b: Path = Path("./subdir/../notes.md").resolve()
a == b # True
Un cugino stretto che non tocca il filesystem è Path.absolute(): aggiunge solo la directory di lavoro corrente davanti se il percorso è relativo. Non collassa i segmenti .. e non risolve i symlink. Per pura matematica sui percorsi senza I/O, absolute() è lo strumento giusto. Per “questo è lo stesso file di quello”, è resolve().
C’è anche samefile():
Path("notes.md").samefile(Path("./subdir/../notes.md"))
# True: stesso inode
Utile per “è questo il file che penso sia” senza normalizzare a mano.
Rinominare e spostare
src: Path = Path("draft.txt")
src.rename(Path("final.txt")) # rinomina sul posto
# Tra directory: stessa operazione
src.rename(Path("archive/final.txt"))
# Sostituisce, anche se la destinazione esiste
src.replace(Path("final.txt"))
rename fallirà su alcune piattaforme se la destinazione esiste; replace sovrascrive. Per le copie (preservando l’originale), usa shutil.copy2 (preserva i metadati) o shutil.copy (solo i contenuti):
import shutil
shutil.copy2(Path("source.csv"), Path("backup/source.csv"))
Tutti questi accettano direttamente oggetti Path. shutil è path-aware in tutto il modulo.
PurePath vs Path: la distinzione che nessuno spiega
Path fa I/O. PurePath no. PurePath è la stessa API di manipolazione dei percorsi meno tutto ciò che tocca il filesystem.
from pathlib import PurePath, PurePosixPath, PureWindowsPath
p: PurePosixPath = PurePosixPath("/etc/hosts")
p.parent # funziona
p.suffix # funziona
p.exists() # AttributeError: nessun accesso al filesystem
Quando è utile? Due casi:
- Test. Vuoi verificare che la tua funzione costruisca il percorso giusto senza preparare un filesystem finto. Passale un
PurePath. - Manipolazione cross-platform. Sei su Linux ma stai parsando percorsi da un log file Windows.
PureWindowsPathcapisce i separatori Windows e le lettere di drive anche su un host POSIX.
La maggior parte del codice usa il semplice Path. Sappi che PurePath esiste, vai avanti.
Riscrivere uno script os.path in pathlib
Ecco un pezzo di codice nel vecchio stile:
import os
import os.path
def collect_logs(root: str) -> list[str]:
out: list[str] = []
for dirpath, _, filenames in os.walk(root):
for name in filenames:
if name.endswith(".log"):
full = os.path.join(dirpath, name)
if os.path.getsize(full) > 0:
out.append(full)
return out
archive_dir = os.path.join(root, "archive")
if not os.path.isdir(archive_dir):
os.makedirs(archive_dir)
for path in collect_logs("/var/log"):
base = os.path.basename(path)
dest = os.path.join(archive_dir, base + ".processed")
with open(path, "r", encoding="utf-8") as src, open(dest, "w", encoding="utf-8") as dst:
dst.write(src.read())
Stessa logica in pathlib:
from pathlib import Path
def collect_logs(root: Path) -> list[Path]:
return [p for p in root.rglob("*.log") if p.stat().st_size > 0]
archive_dir: Path = root / "archive"
archive_dir.mkdir(parents=True, exist_ok=True)
for path in collect_logs(Path("/var/log")):
dest: Path = archive_dir / (path.name + ".processed")
dest.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")
Metà delle righe. Niente giochetti con stringhe. Type signature che dicono “questo è un percorso”, non “questa è una stringa qualunque che per caso è un percorso”. Quando rileggi la seconda versione fra un anno, capisci a colpo d’occhio cosa sta succedendo.
Info di stat: dimensione, mtime, modalità
Quando ti serve metadata del file, stat() restituisce un os.stat_result con tutto quello che il sistema operativo sa del file:
info = Path("events.json").stat()
info.st_size # byte
info.st_mtime # ultima modifica, secondi dall'epoca (float)
info.st_ctime # tempo di creazione su Windows, cambio di metadata su POSIX
info.st_mode # bit di permessi e tipo di file
Per tempi di modifica leggibili dagli umani, converti con datetime:
from datetime import datetime, timezone
mtime: datetime = datetime.fromtimestamp(
Path("events.json").stat().st_mtime,
tz=timezone.utc,
)
Path.stat() segue i symlink; Path.lstat() no. Se vuoi sapere se un percorso è un symlink a un file o il file stesso, usa lstat() e controlla la modalità.
Lavorare con la directory di lavoro corrente
Path.cwd() # percorso assoluto di dove gira lo script
Path("data/raw").is_absolute() # False, relativo
Path("data/raw").absolute() # antepone cwd, nessuna risoluzione di symlink
Un Path relativo viene risolto rispetto alla cwd ogni volta che fai I/O. Questo significa che uno script che usa Path("data/raw/events.json") leggerà file diversi a seconda di dove lo lanci. Se vuoi un percorso relativo allo script stesso, ancoralo con __file__:
HERE: Path = Path(__file__).resolve().parent
DATA: Path = HERE / "data" / "raw"
Ora DATA punta allo stesso posto indipendentemente da dove lo script viene invocato. Questo è il pattern giusto per qualunque script che porta con sé i propri file di dati.
La manciata di metodi che vale la pena memorizzare
Se ti ricordi nient’altro di questa lezione:
Path("...") / "..." / "..."per unire.parent,.name,.stem,.suffixper le parti.exists(),.is_file(),.is_dir()per i controlli.read_text(encoding="utf-8"),.write_text(...)per file piccoli.glob("*.csv"),.rglob("**/*.py")per cercare.mkdir(parents=True, exist_ok=True)per creare.unlink(missing_ok=True)eshutil.rmtree(...)per cancellare
Questo è il 95%. L’API completa ha altri cinquanta metodi per symlink, permessi, ownership, hardlink, risoluzione: esistono quando ti servono. La maggior parte degli script non ne ha bisogno.
Prossima lezione: la palude dei timezone. Datetime naive vs aware, perché pytz è finalmente in pensione a favore di zoneinfo della stdlib, e la trappola del DST che rompe l’aritmetica “1 giorno dopo” due volte all’anno.
Riferimenti: pathlib — Object-oriented filesystem paths, shutil — High-level file operations. Recuperato il 2026-05-01.