Python Check if File Exists: pathlib, os.path and Safe Patterns
Path("x").is_file() checks for a file, is_dir() for a folder and exists() for either. Plus create-if-missing, delete-if-present, wildcards, empty files and race conditions.
Python's pathlib module gives you object-oriented filesystem paths: join them with the / operator, read and write text with read_text and write_text, create directories with mkdir, and find files with glob and rglob.
Python's pathlib module gives you object-oriented filesystem paths: join them with the / operator, read and write text with read_text and write_text, create directories with mkdir, and find files with glob and rglob. It is the modern replacement for stringy os.path joins in most new code.
This tutorial shows Path basics, joining, reading and writing, globbing, mkdir options, and a side-by-side comparison with os.path. Samples follow the style of the official docs at docs.python.org/3/library/pathlib.html. When you need URL-safe names for files you create, the slug generator can help shape filenames.
Examples use Python 3 spelling throughout. If you maintain 2.7-era code, pathlib still exists via backport packages, but this guide assumes a current Python 3 install.
Skim the tables first if you already know os.path; the side-by-side section maps each old habit to a Path method so you can migrate line by line.
Contents
Import Path from pathlib. Construct paths from strings or from other paths. The / operator joins parts regardless of operating system separators.
from pathlib import Path
p = Path("/home/user/docs") / "report.txt"
print(p) # /home/user/docs/report.txt
print(p.name) # report.txt
print(p.stem) # report
print(p.suffix) # .txt
print(p.parent) # /home/user/docs
print(p.parts) # ('/', 'home', 'user', 'docs', 'report.txt')On Windows, Path still accepts forward slashes in code and prints with backslashes when needed. Write Path("C:/data") / "file.txt" and avoid hard-coding \\ chains.
Path.cwd() is the current working directory. Path.home() is the user home. PurePosixPath and PureWindowsPath exist for flavour-specific pure parsing without touching the disk.
joinpath does the same joining as / when you prefer a method call: p.joinpath("a", "b.txt").
Absolute vs relative matters: Path("docs") / "a.txt" depends on the process cwd, while (Path(__file__).parent / "docs" / "a.txt") does not. Prefer the second in applications.
from pathlib import Path
rel = Path("docs") / "a.txt"
print(rel.is_absolute()) # False
print(rel.resolve()) # absolute form based on cwdProperties such as suffixes handle multiple extensions: Path("archive.tar.gz").suffixes returns ['.tar', '.gz']. Use that when you write decompressors.
Equality compares the path object representation, not the resolved target of symlinks. Call resolve() when you need to know where a link points.
Concrete paths talk to the filesystem; pure paths only manipulate strings. In tests that should not touch disk, PurePath can still validate joins and suffixes. In application code, Path (concrete) is what you want.
Copying a path into logs? Print the Path directly; its str form is readable. For JSON configs, store strings and rehydrate with Path when the process starts.
Once Path feels natural, string joins start to look noisy in review. That is the signal you have internalised the module.
Readable path code also helps security reviews. When every join is visible as / or joinpath, reviewers spot user-controlled segments faster than when joins hide inside concatenated f-strings.
Keep a project-level ROOT = Path(__file__).resolve().parents[n] in packages where the layout is stable. Document what n means so refactors that move files update the anchor deliberately.
from pathlib import Path
notes = Path("notes.txt")
notes.write_text("hello from pathlib\n", encoding="utf-8")
text = notes.read_text(encoding="utf-8")
print(text)
data = Path("blob.bin")
data.write_bytes(b"\x00\x01\x02")
print(data.read_bytes())Always pass encoding="utf-8" for text unless you know you need another encoding. Explicit encodings avoid surprises across machines. For large files, open a path with path.open() and iterate lines instead of reading everything into memory.
path = Path("large.log")
with path.open(encoding="utf-8") as f:
for line in f:
if "ERROR" in line:
print(line, end="")write_text overwrites by default. It returns the number of characters written. Create parent directories first if they might be missing (see mkdir below).
Compared with open("file.txt") alone, keeping a Path object makes later joins, renames and sibling paths easier to express.
Atomic replace pattern for config files:
from pathlib import Path
import os
target = Path("config.json")
tmp = target.with_suffix(".json.tmp")
tmp.write_text('{"ok": true}\n', encoding="utf-8")
os.replace(tmp, target) # atomic on most OSesWriting to a temp sibling then replacing avoids half-written configs if the process crashes mid-write.
read_text raises if the file is missing. Check exists() or catch FileNotFoundError when absence is normal.
Newline handling follows universal newlines for text mode. Stick to \n in files you control for consistency across teams.
Binary formats (images, pickle, protobuf) should use read_bytes/write_bytes or open with mode "rb"/"wb". Never decode arbitrary binary as UTF-8.
If you write CSV, consider the csv module with a Path.open handle rather than hand-rolled commas. Pathlib gets you to the file; specialised modules handle the format.
Teachers and interviewers still show open first. That is fine for the first hour of Python. The moment a script grows a second file path, switch to Path so the rest of the program stays consistent.
Encoding errors should fail loudly during development. Using errors="ignore" hides corruption. Prefer strict defaults, and only relax encoding error modes at deliberate boundaries such as messy third-party logs.
from pathlib import Path
root = Path(".")
for py in root.glob("*.py"):
print(py)
for py in root.rglob("*.py"): # recursive
print(py)
prints = list(root.glob("docs/**/*.md")) # ** pattern
print(len(prints))glob matches in one directory level unless the pattern contains **. rglob("*.py") is shorthand for recursive search. Both return generators; wrap with list() only when you need a length or multiple passes.
match tests a relative pattern against a path: Path("a/b.py").match("*.py") is false because of the directory part, while match("**/*.py") can be true depending on pattern rules—check the docs when patterns get fancy.
For filename cleanup before you write, slugify titles into safe names. The on-site slug generator turns headlines into lowercase hyphenated strings you can append a suffix to.
Exclude directories by filtering results:
from pathlib import Path
root = Path(".")
files = [p for p in root.rglob("*.py") if ".venv" not in p.parts and "migrations" not in p.parts]
print(len(files))Generators make that filtering cheap. Avoid **/node_modules/** walks on large JavaScript monorepos without excludes.
Case sensitivity follows the platform. A pattern that works on macOS APFS defaults may miss files on a case-sensitive Linux CI volume. Test globs in CI.
Sorted output helps snapshots in tests: sorted(root.glob("*.txt")). Generators alone do not guarantee order across platforms.
When migrating shell scripts, remember that pathlib globs do not expand environment variables. Call os.path.expandvars or replace variables yourself before building the Path.
Build tools often need deterministic file lists for caches. Sort paths by their as_posix() string so Windows and Linux CI produce the same order in lockfiles and manifests.
When wrapping legacy glob results that return strings, convert with map(Path, results) immediately so the rest of your function never mixes types.
from pathlib import Path
out = Path("data") / "exports"
out.mkdir(parents=True, exist_ok=True)
src = Path("draft.txt")
src.write_text("x", encoding="utf-8")
dest = src.with_name("draft-final.txt")
src.rename(dest)
print(dest.exists()) # True
print(dest.is_file()) # True
dest.unlink() # delete file
out.rmdir() # delete empty dirparents=True creates intermediate directories. exist_ok=True avoids errors when the directory already exists—similar to mkdir -p in shells.
with_suffix(".md") swaps extensions. with_name replaces the final component. These helpers beat manual string slicing and keep behaviour consistent on Windows.
| Method | Purpose |
|---|---|
| exists() | Path exists |
| is_file() / is_dir() | Type checks |
| stat() | Size and timestamps |
| resolve() | Absolute path, follow symlinks |
| expanduser() | Expand ~ |
Touching a file (create empty if missing) is a tiny recipe:
from pathlib import Path
p = Path("flags") / "ready.txt"
p.parent.mkdir(parents=True, exist_ok=True)
p.touch(exist_ok=True)touch updates mtime when the file already exists. Handy for signalling build steps.
Permissions show up via stat().st_mode; changing them uses chmod on the Path. Prefer explicit octal modes in deployment scripts.
Replacing a directory tree safely is still shutil territory. Pathlib covers single-path operations well; composition with shutil.move and shutil.copy2 is normal and encouraged.
On Windows, watch for file locks from antivirus or editors when unlinking. Retry loops with a short sleep solve many flaky test teardowns.
Idempotent setup functions should call mkdir(exist_ok=True) and then assert is_dir(). That pattern survives reruns in notebooks and deploy hooks without noisy exceptions.
Renames across filesystems may fall back to copy-plus-delete depending on the OS. For huge artefacts, prefer an explicit copy API when you need progress meters or checksum verification.
| Task | os.path style | pathlib style |
|---|---|---|
| Join | os.path.join(a, b) | Path(a) / b |
| Base name | os.path.basename(p) | Path(p).name |
| Directory | os.path.dirname(p) | Path(p).parent |
| Split ext | os.path.splitext(p) | .stem and .suffix |
| Read text | open + read | Path.read_text() |
| List py files | glob.glob / os.listdir | Path.glob('*.py') |
os.path still works and appears in older tutorials. New code should prefer pathlib for readability. You can pass Path objects to many APIs that accept path-like objects (os.PathLike) since Python 3.6+. When a library demands a string, call str(path) or use os.fspath(path).
The standard library documentation linked in the introduction is the authoritative reference for edge cases such as drive letters, UNC paths and pure vs concrete paths.
Migration tip: introduce Path at module edges first (CLI arguments, config roots), then replace inner os.path.join calls as you touch each function. You do not need a big-bang rewrite.
os.listdir returns strings; Path.iterdir yields Path objects you can chain immediately. That chaining is the readability win.
from pathlib import Path
for child in Path(".").iterdir():
if child.is_file() and child.suffix == ".csv":
print(child.name, child.stat().st_size)Performance is rarely the reason to avoid pathlib. Creating Path objects is cheap compared with actual disk I/O. Choose based on clarity.
Some low-level modules still document string paths only. Passing Path usually works via os.fspath. If a C extension breaks, convert at the call site and open an issue upstream.
Style guides such as many corporate Python standards already recommend pathlib for new modules. Following that guidance keeps onboarding simpler: one path story, not two.
If you maintain a library API, accept str | os.PathLike[str] and normalise with Path(user_value) at the boundary. Callers can pass either strings or Paths without friction.
from pathlib import Path
ROOT = Path(__file__).resolve().parent
CONFIG = ROOT / "config.json"
LOGS = ROOT / "logs"
LOGS.mkdir(exist_ok=True)
def export_csv(name: str, rows: list[str]) -> Path:
safe = "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in name).strip("-")
target = LOGS / f"{safe}.csv"
target.write_text("\n".join(rows) + "\n", encoding="utf-8")
return targetAnchoring paths to Path(__file__).parent keeps projects runnable regardless of the shell's working directory. That pattern alone removes a whole class of "file not found" bugs in scripts and tests.
For bulk renames, iterate Path.glob, build a destination with with_suffix or with_name, then rename. Dry-run by printing pairs first.
Unit tests can use tmp_path fixtures in pytest, which already return pathlib Paths. Write assertions against tmp_path / "out.txt" instead of string concatenation.
Logging setup example:
from pathlib import Path
import logging
log_dir = Path(__file__).resolve().parent / "logs"
log_dir.mkdir(exist_ok=True)
logging.basicConfig(filename=log_dir / "app.log", level=logging.INFO)Many logging APIs accept Path objects directly on modern Python. If yours does not, wrap with str().
Data science notebooks benefit from a single DATA = Path("data") cell. All later loads use DATA / "raw" / name. Readers instantly see where files live.
CLI tools often accept an argument that might be "-" for stdin. Keep that convention: if args.input != "-", then path = Path(args.input).read_text(...); else read sys.stdin.
Package data access in installed distributions may need importlib.resources instead of __file__ relative paths. Use pathlib inside the resource API when you materialise a temporary path.
Configuration discovery often walks upward from Path.cwd() looking for pyproject.toml or .git. Path makes that walk obvious: for parent in [cwd, *cwd.parents]:.
Cache directories belong under Path.home() / ".cache" / "yourapp" on Unix-like systems, with a Windows equivalent under LOCALAPPDATA when you care about platform norms. Abstract that behind a small function that returns a Path.
__file__ or an explicit root.node_modules or .git.shutil.rmtree with care.When a relative path misbehaves, print path.resolve() and Path.cwd() side by side. Most bugs become obvious immediately.
pathlib will not replace every filesystem need: watching files, locking and advanced permissions still use other modules. For everyday joins, reads, writes and globs, Path should be your default.
Another footgun: building paths from unsanitised user input. Reject .. segments or resolve and assert the result stays under an allowed root before you read or write.
from pathlib import Path
BASE = Path("/var/app/uploads").resolve()
def safe_join(user_name: str) -> Path:
candidate = (BASE / user_name).resolve()
if not candidate.is_relative_to(BASE):
raise ValueError("path escapes upload root")
return candidateis_relative_to arrived in Python 3.9 and is the clearest way to express that guard.
Do not use pathlib to execute shell commands. Pass Path objects into subprocess with an explicit argument list, never a concatenated shell string, to avoid injection issues.
Finally, keep user-facing paths tidy: when you display errors, show the path the user gave you, not a surprise resolve() result, unless the resolution itself is the point of the message.
A final reminder: pathlib shines at path manipulation and everyday I/O. It does not remove the need to think about encoding, atomic writes, TOCTOU races, or permission errors. Pair it with solid error handling and your file code stays both clean and robust.
It is a standard-library module that represents filesystem paths as objects, with methods for joining, reading, writing, globbing and mkdir. The official pathlib documentation covers additional edge cases for each method.
Use the / operator: Path("dir") / "file.txt", or call joinpath with one or more parts. The official pathlib documentation covers additional edge cases for each method.
Call Path("file.txt").read_text(encoding="utf-8") for text, or read_bytes() for binary data. The official pathlib documentation covers additional edge cases for each method.
glob matches within the pattern you give; rglob("*.ext") searches recursively. Patterns may also include **. The official pathlib documentation covers additional edge cases for each method.
Prefer pathlib for new code. Keep os.path when maintaining older modules, or convert at boundaries with Path and str(). The official pathlib documentation covers additional edge cases for each method.
Use Path("a/b").mkdir(parents=True, exist_ok=True) to mimic mkdir -p behaviour. The official pathlib documentation covers additional edge cases for each method.
Yes. Path handles drive letters and separators. Prefer / in source code; Path normalises as needed. The official pathlib documentation covers additional edge cases for each method.
Yes. open() accepts path-like objects. You can also use path.open() directly. The official pathlib documentation covers additional edge cases for each method.
Last checked: 18 October 2026. Specs and docs can change, so confirm anything important with the official source linked above.