pathlib thay cho os.path: xử lý đường dẫn file kiểu hiện đại
1. Tổng quan
Nhiều code Python cũ xử lý đường dẫn bằng os.path.join, os.path.splitext, os.listdir… Các hàm này nhận và trả về chuỗi, nên code dài, khó đọc và dễ sai. Module pathlib (có từ Python 3.4) biểu diễn đường dẫn bằng object Path có đủ method để ghép, tách, đọc, ghi và duyệt file.
Đọc xong bài này, bạn sẽ chuyển được phần lớn code os.path sang pathlib và biết những chỗ dễ vấp. Ví dụ trong bài dùng Python 3.12.
2. Tạo đối tượng Path
Path là điểm bắt đầu cho mọi thao tác:
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
Giải thích:
Path("data/reports")tạo object đường dẫn. Lúc này chưa có thao tác nào với ổ đĩa. Thư mục không cần tồn tại.- Trên Linux/macOS,
Pathtrả vềPosixPath. Trên Windows, nó trả vềWindowsPath. Bạn không cần quan tâm lớp cụ thể. Path.home()vàPath.cwd()thay choos.path.expanduser("~")vàos.getcwd().
Nếu chỉ cần thao tác chuỗi đường dẫn mà không đụng tới file thật, bạn dùng PurePath, PurePosixPath hoặc PureWindowsPath. Cách này tiện để xử lý đường dẫn Windows khi đang chạy trên Linux.
3. Ghép đường dẫn với toán tử /
Đây là thay đổi dễ thấy nhất. Thay vì lồng os.path.join, bạn dùng toán tử /:
import os
from pathlib import Path
# Cách cũ
old = os.path.join("data", "orders", "2026", "order_1001.json")
# Cách mới
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
Toán tử / nhận cả Path lẫn str ở vế phải. Kết quả luôn là Path, nên bạn có thể ghép tiếp. Dấu phân cách tự đổi theo hệ điều hành: / trên Linux, \ trên Windows.
Nếu số thành phần thay đổi lúc chạy, bạn dùng joinpath:
parts = ["orders", "2026", "order_1001.json"]
path = Path("data").joinpath(*parts)
print(path) # data/orders/2026/order_1001.json
4. Tách các thành phần của đường dẫn
os.path cần nhiều hàm cho việc này: basename, dirname, splitext. Với pathlib, mọi thứ là thuộc tính:
from pathlib import Path
invoice = Path("/srv/shop/invoices/2026/invoice_042.final.pdf")
print(invoice.name) # tên file đầy đủ
print(invoice.stem) # tên không có đuôi cuối
print(invoice.suffix) # đuôi cuối
print(invoice.suffixes) # tất cả các đuôi
print(invoice.parent) # thư mục chứa
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')
Giải thích:
stemvàsuffixchỉ tách ở dấu chấm cuối cùng. Với file.tar.gz, bạn cần dùngsuffixes.parenttương đươngos.path.dirname.parentslà dãy các thư mục cha, đi dần lên gốc.partstách đường dẫn thành tuple, tiện khi cần so sánh từng cấp.
Muốn tạo đường dẫn mới với tên hoặc đuôi khác, bạn dùng các method 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
Các method này không đổi tên file trên đĩa. Chúng chỉ trả về một Path mới, vì Path là immutable.
5. Đọc và ghi file
Với file nhỏ, bạn không cần 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_textmở file, ghi rồi đóng. File cũ bị ghi đè.read_textđọc toàn bộ nội dung thành chuỗi.write_bytesvàread_byteslàm việc tương tự với dữ liệu nhị phân.
Với file lớn hoặc khi cần đọc từng dòng, bạn vẫn dùng context manager. Path có sẵn method 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
Lưu ý: Luôn truyền
encoding="utf-8". Nếu bỏ qua, Python dùng encoding mặc định của hệ thống. Trên Windows, mặc định thường làcp1252, nên dữ liệu tiếng Việt sẽ lỗi.
6. Kiểm tra và duyệt thư mục
6.1. Kiểm tra trạng thái
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() trả về đường dẫn tuyệt đối và giải các symlink. Nó thay cho os.path.abspath và os.path.realpath.
6.2. Liệt kê và tìm file
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()thay choos.listdir(). Nó trả vềPathđầy đủ, không chỉ tên file, nên bạn không cần ghép lại.glob("*.md")chỉ tìm trong thư mục hiện tại.rglob("*.json")tìm đệ quy, tương đươngglob("**/*.json").
Từ Python 3.12, Path.walk() thay được cho os.walk():
for dirpath, dirnames, filenames in Path("shop").walk():
print(dirpath, filenames)
7. Tạo, đổi tên và xóa
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)tương đươngos.makedirs(..., exist_ok=True).renametrả vềPathmới. Trên Windows,renamebáo lỗi nếu file đích đã tồn tại. Muốn ghi đè ở mọi nền tảng, bạn dùngreplace.unlinkxóa file. Vớimissing_ok=True, nó không báo lỗi khi file không tồn tại.rmdirchỉ xóa được thư mục rỗng. Để xóa cả cây thư mục, bạn vẫn cầnshutil.rmtree. Hàm này nhậnPathbình thường.
8. Bảng đối chiếu nhanh
| 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() hoặc p.absolute() |
os.path.expanduser("~") |
Path.home() hoặc 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. Viết test với pytest
Fixture tmp_path của pytest trả về sẵn một Path, nên test code dùng pathlib rất gọn. Giả sử bạn có hàm gom các file hóa đơn PDF vào thư mục lưu trữ:
# 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
Test tương ứng:
# 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
Thứ tự trong moved là b.pdf trước a.pdf. Lý do: sorted so sánh object Path, và inbox/2026/b.pdf đứng trước inbox/a.pdf vì ký tự 2 nhỏ hơn a. Chi tiết này dễ bị bỏ qua khi bạn chuyển từ so sánh chuỗi tên file sang so sánh Path.
10. Lỗi hay gặp
Cộng Path với chuỗi. Path("data") + "/x.txt" báo TypeError. Bạn dùng / thay cho +. Nếu thật sự cần nối vào tên file, bạn dùng p.with_name(p.name + "_old").
Vế phải là đường dẫn tuyệt đối. Kết quả có thể khiến bạn bất ngờ:
from pathlib import Path
print(Path("/srv/uploads") / "/etc/passwd") # /etc/passwd
Thành phần tuyệt đối ở bên phải thay thế toàn bộ phần bên trái. Nếu tên file đến từ người dùng, bạn phải kiểm tra trước khi dùng:
base = Path("/srv/uploads").resolve()
candidate = (base / user_input).resolve()
if not candidate.is_relative_to(base):
raise ValueError("Đường dẫn không hợp lệ")
with_suffix thiếu dấu chấm. p.with_suffix("pdf") báo ValueError. Bạn phải viết ".pdf".
Quên rằng glob trả về generator. Bạn chỉ duyệt được nó một lần. Nếu cần dùng lại hoặc gọi len(), hãy bọc trong list(...). Thứ tự kết quả cũng không được đảm bảo, nên bạn cần sorted khi thứ tự quan trọng.
File ẩn và stem. Với Path(".env"), suffix là chuỗi rỗng và stem là ".env". Đừng giả định mọi file đều có đuôi.
Thư viện cũ chỉ nhận str. Hầu hết API chuẩn đã nhận os.PathLike từ Python 3.6. Nếu gặp thư viện cũ, bạn dùng str(p) hoặc os.fspath(p).
Lưu ý:
pathlibkhông thay thế hoàn toànosvàshutil. Để sao chép file, bạn vẫn dùngshutil.copy2(Python 3.14 mới bổ sungPath.copy). Biến môi trường vẫn đọc quaos.environ. Quyền truy cập phức tạp vẫn cầnos.chmodhoặcos.access.
Khi nào không cần đổi? Với vòng lặp xử lý hàng triệu chuỗi đường dẫn mà không đụng tới đĩa, tạo object Path chậm hơn thao tác chuỗi thuần. Đây là trường hợp hiếm, và bạn nên đo trước khi tối ưu. Với code cũ đang chạy ổn định, bạn không cần sửa hàng loạt. Hãy chuyển dần khi động vào từng module.
11. Kết luận
pathlib gom các thao tác rải rác trong os, os.path và glob vào một object duy nhất. Code ngắn hơn, đọc tự nhiên hơn và ít lỗi ghép chuỗi hơn. Bạn đã thấy cách ghép đường dẫn bằng /, tách thành phần bằng name, stem, suffix, parent, đọc ghi file bằng read_text và write_text, duyệt thư mục bằng iterdir, glob, rglob, walk, cùng cách test bằng tmp_path của pytest.
Bạn nên nhớ ba điểm. Luôn truyền encoding. Cẩn thận khi ghép với đường dẫn tuyệt đối từ input bên ngoài. Vẫn dùng shutil cho các thao tác như sao chép và xóa cây thư mục. Với code mới, hãy mặc định dùng Path và chỉ quay về os.path khi có lý do cụ thể.