何が問題だったか
Python でファイルを扱うとき、長らくこう書いていました。
import os
base = os.path.dirname(os.path.abspath(__file__))
data_dir = os.path.join(base, "data")
if not os.path.exists(data_dir):
os.makedirs(data_dir)
path = os.path.join(data_dir, "result.csv")
動きます。動きますが、os.path.dirname(os.path.abspath(...)) を
毎回書くのがつらいし、os.path.join が入れ子になると読めません。
pathlib に変えるとこうなります。
from pathlib import Path
data_dir = Path(__file__).resolve().parent / "data"
data_dir.mkdir(parents=True, exist_ok=True)
path = data_dir / "result.csv"
パスが「文字列」ではなく「パスというオブジェクト」になるのが本質で、
そこから来る恩恵がいくつもあります。
/ で連結できる
いちばん目を引くのがこれです。
Path("data") / "2026" / "result.csv"
# WindowsPath('data/2026/result.csv')
割り算の演算子をパス連結に使うのは最初は奇妙ですが、
os.path.join(os.path.join("data", "2026"), "result.csv") と比べれば
読みやすさは明らかです。
区切り文字を自分で書かないので、OS の違いを気にしなくてよくなります。
Windows でも / で書けて、内部で \ に変換されます。
p = Path("data") / "result.csv"
print(p) # Windows なら data\result.csv
print(p.as_posix()) # data/result.csv (常にスラッシュ)
文字列で "data/" + name と書いてしまうと、
末尾のスラッシュを忘れたり二重になったりする事故が起きます。
よく使うメソッド
場所を取る
p = Path("/home/user/docs/report.txt")
p.name # 'report.txt' ファイル名
p.stem # 'report' 拡張子を除いた名前
p.suffix # '.txt' 拡張子(ドット込み)
p.parent # Path('/home/user/docs')
p.parents[1] # Path('/home/user')
p.stem は地味にありがたいです。
os.path.splitext(os.path.basename(p))[0] を書かなくて済みます。
拡張子や名前を差し替える
p.with_suffix(".csv") # Path('/home/user/docs/report.csv')
p.with_name("other.txt") # Path('/home/user/docs/other.txt')
「同じ場所に拡張子だけ変えて保存」がこれ 1 行で書けます。
output = input_path.with_suffix(".json")
存在確認・作成
p.exists() # ある?
p.is_file() # ファイル?
p.is_dir() # ディレクトリ?
d.mkdir(parents=True, exist_ok=True)
mkdir の 2 つの引数を覚えると、os.makedirs の分岐が消えます。
-
parents=True… 途中のディレクトリも作る(mkdir -p相当) -
exist_ok=True… 既にあってもエラーにしない
これが無いと FileExistsError を握りつぶす try を書く羽目になります。
読み書きのショートカット
小さいファイルなら open すら要りません。
text = p.read_text(encoding="utf-8")
p.write_text("こんにちは", encoding="utf-8")
data = p.read_bytes()
p.write_bytes(b"\x00\x01")
encoding は必ず明示します。 省略すると OS の既定に従うため、
Windows では cp932 になって、日本語で UnicodeDecodeError が出ることがあります。
(Python 3.15 で既定が UTF-8 になる予定ですが、明示する癖は付けておいて損はありません。)
大きいファイルは今まで通り open を使います。Path はそのまま渡せます。
with p.open(encoding="utf-8") as f:
for line in f:
...
ファイルを探す
glob モジュールを別途 import しなくて済みます。
for csv in Path("data").glob("*.csv"):
print(csv)
for py in Path("src").rglob("*.py"): # 再帰的に
print(py)
rglob("*.py") は glob("**/*.py") と同じです。短いほうを使っています。
削除・リネーム
p.unlink() # ファイル削除
p.unlink(missing_ok=True) # 無くてもエラーにしない
d.rmdir() # 空のディレクトリを削除
p.rename(p.with_suffix(".bak"))
ディレクトリを中身ごと消すのは pathlib にはありません。shutil.rmtree を使います。
__file__ からの相対パス
スクリプトと同じ場所にあるファイルを読みたい、というのはよくあります。
# ダメな例:カレントディレクトリ依存
config = Path("config.json")
これはどこから実行したかで結果が変わります。
python src/main.py と cd src && python main.py で挙動が違う。
# 良い例:スクリプトの位置基準
BASE_DIR = Path(__file__).resolve().parent
config = BASE_DIR / "config.json"
resolve() はシンボリックリンクを解決して絶対パスにします。
これを付けておかないと parent が . になることがあります。
パッケージ内なら、階層のぶんだけ .parent を重ねます。
# myapp/config.py から見て、myapp/ の 1 つ上(プロジェクトルート)
BASE_DIR = Path(__file__).resolve().parent.parent
標準ライブラリはだいたい Path を受け取る
「Path に変えたら他のライブラリで動かないのでは」と心配になりますが、
Python 3.6 以降、標準ライブラリの多くは Path をそのまま受け取ります。
import json, sqlite3, csv
json.loads(path.read_text(encoding="utf-8"))
sqlite3.connect(db_path) # Path でよい
open(path, encoding="utf-8") # Path でよい
受け取ってくれない古いライブラリには str() で渡します。
some_old_api(str(path))
内部で文字列として保持したいときも、素直に変換してしまうのがトラブルが少ないです。
class Repository:
def __init__(self, db_path):
self.db_path = str(db_path) # Path でも str でも受け取れる
os.path に残る利点
全部 pathlib にすべきかというと、そうでもありません。
-
大量のパスをループで処理するとき、
os.pathのほうが速いことがあります
(Pathオブジェクトの生成コストがある) -
os.path.joinは文字列を返すので、そのまま文字列連結したいときは素直
とはいえ、普通のアプリで問題になる差ではありません。
まず pathlib、困ったら os.path で十分だと思います。
まとめ
| やりたいこと | pathlib |
|---|---|
| 連結 | base / "sub" / "file.txt" |
| ファイル名 / 拡張子抜き / 拡張子 |
.name / .stem / .suffix
|
| 親ディレクトリ | .parent |
| 拡張子を変える | .with_suffix(".csv") |
| ディレクトリ作成 | .mkdir(parents=True, exist_ok=True) |
| 読み書き |
.read_text(encoding="utf-8") / .write_text(...)
|
| 検索 |
.glob("*.csv") / .rglob("*.py")
|
| スクリプト基準 | Path(__file__).resolve().parent |
パスを文字列として扱うのをやめただけで、
「区切り文字」「カレントディレクトリ依存」「拡張子の切り出し」あたりの
細かいバグがまとめて消えました。