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, Path trả 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 cho os.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:

  • stem và suffix chỉ tách ở dấu chấm cuối cùng. Với file .tar.gz, bạn cần dùng suffixes.
  • parent tương đương os.path.dirname. parents là dãy các thư mục cha, đi dần lên gốc.
  • parts tá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_text mở file, ghi rồi đóng. File cũ bị ghi đè.
  • read_text đọc toàn bộ nội dung thành chuỗi.
  • write_bytes và read_bytes là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 cho os.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 đương glob("**/*.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 đương os.makedirs(..., exist_ok=True).
  • rename trả về Path mới. Trên Windows, rename báo lỗi nếu file đích đã tồn tại. Muốn ghi đè ở mọi nền tảng, bạn dùng replace.
  • unlink xóa file. Với missing_ok=True, nó không báo lỗi khi file không tồn tại.
  • rmdir chỉ xóa được thư mục rỗng. Để xóa cả cây thư mục, bạn vẫn cần shutil.rmtree. Hàm này nhận Path bì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 ý: pathlib không thay thế hoàn toàn os và shutil. Để sao chép file, bạn vẫn dùng shutil.copy2 (Python 3.14 mới bổ sung Path.copy). Biến môi trường vẫn đọc qua os.environ. Quyền truy cập phức tạp vẫn cần os.chmod hoặc os.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ể.

pythonpathlibos.pathfile-iopython3