Python Paths and pathlib
Learn to build, inspect, search, compare, normalize, expand, split, and convert filesystem paths with pathlib and selected os.path APIs, including directory traversal, path identity, environment-variable expansion, drive/root/tail decomposition, path representations, home expansion, resolution, and relative paths.
Intermediate
A filesystem path identifies a location in a filesystem. Python’s pathlib module represents paths with Path objects, giving you a platform-aware way to build and inspect paths without manually assembling path strings.
Create a Path
Path is the main pathlib class for ordinary platform-specific filesystem paths. Creating a Path object does not by itself create a file or directory.
from pathlib import Path
config = Path('project') / 'config' / 'settings.json'
print(config)Build paths with /
Use the / operator to combine path components. This is path joining, not string concatenation, and Path handles the separator appropriate to the current platform.
from pathlib import Path
source = Path('project') / 'src' / 'main.py'
print(source)Inspect path parts
Separate a path's drive, root, and tail
A path has more structure than its final filename. For pathlib paths, drive identifies a drive or share when the path flavor has one, root is the root separator, and anchor combines drive and root. For string paths, os.path.splitroot() returns (drive, root, tail): tail is the entire remainder after the root, not just the last filename. The three parts always concatenate back to the original path, and any part may be empty. os.path follows the current operating system; use posixpath or ntpath when you deliberately need to parse POSIX-style or Windows-style paths on any host. This is syntax parsing only: it does not inspect the filesystem or resolve symbolic links. splitroot() is available from Python 3.12.
import ntpath
import posixpath
from pathlib import PurePosixPath, PureWindowsPath
samples = [
(posixpath, "/var/log/app.log"),
(ntpath, r"C:\logs\app.log"),
(ntpath, r"\\server\share\logs\app.log"),
]
for path_module, path in samples:
drive, root, tail = path_module.splitroot(path)
print(path_module.__name__, repr(drive), repr(root), repr(tail))
assert drive + root + tail == path
print(PurePosixPath("/var/log/app.log").anchor)
print(PureWindowsPath(r"C:\logs\app.log").anchor)Identify the root separately from drive and tail
For this POSIX-style absolute path, splitroot() returns (drive, root, tail). Choose the expression that retrieves only the root separator.
import posixpath
parts = posixpath.splitroot("/srv/data/report.csv")
root =
print(root == "/")
Path objects expose useful parts of a path. name is the final component, suffix is the final extension, stem is the final component without its suffix, and parent represents the containing path.
from pathlib import Path
path = Path('reports') / '2026' / 'summary.csv'
print(path.name)
print(path.stem)
print(path.suffix)
print(path.parent)Change a path without changing the filesystem
Methods such as with_name() and with_suffix() return new Path objects. They calculate a different path; they do not rename or modify anything on disk.
from pathlib import Path
source = Path('reports') / 'summary.csv'
print(source.with_suffix('.json'))
print(source.with_name('archive.csv'))Check a real path
Concrete Path objects can query the filesystem. exists(), is_file(), and is_dir() answer basic questions about a path. These operations access the operating system, so their answers depend on the current filesystem.
from pathlib import Path
from tempfile import TemporaryDirectory
with TemporaryDirectory() as folder:
root = Path(folder)
report = root / 'report.txt'
report.write_text('done', encoding='utf-8')
print(report.exists())
print(report.is_file())
print(root.is_dir())Create directories and files explicitly
Path also provides operations that change the filesystem. mkdir() creates a directory, while touch() creates a file. These operations are different from merely constructing a Path object.
from pathlib import Path
from tempfile import TemporaryDirectory
with TemporaryDirectory() as folder:
root = Path(folder)
output = root / 'output'
output.mkdir()
result = output / 'result.txt'
result.touch()
print(output.is_dir())
print(result.is_file())Keep paths separate from file I/O
A Path represents a location. It is not the file contents and it is not a file object. Later, when you learn file I/O, you can use a Path with open() or Path.open(). Keeping these concepts separate makes filesystem code easier to reason about.
from pathlib import Path
path = Path('data') / 'records.txt'
print(path)
print(type(path).__name__)Inspect a directory’s immediate children
Path.iterdir() yields Path objects for the entries directly inside a directory. It does not descend into child directories. The order is not guaranteed, so sort the paths by name when readable, repeatable output matters.
from pathlib import Path
from tempfile import TemporaryDirectory
with TemporaryDirectory() as temporary:
root = Path(temporary)
(root / "archive").mkdir()
(root / "notes.txt").write_text("draft\n", encoding="utf-8")
(root / "archive" / "report.csv").write_text("name\nAda\n", encoding="utf-8")
for path in sorted(root.iterdir(), key=lambda item: item.name):
kind = "directory" if path.is_dir() else "file"
print(path.name, kind)List only the immediate children
Complete the expression so the list contains entries immediately inside root, not entries nested in its subdirectories.
from pathlib import Path
from tempfile import TemporaryDirectory
with TemporaryDirectory() as temporary:
root = Path(temporary)
(root / "folder").mkdir()
(root / "top.txt").touch()
(root / "folder" / "nested.txt").touch()
names = sorted(
path.relative_to(root).as_posix()
for path in root.
)
print(names)
Find matching paths with glob patterns
Path.glob(pattern) yields entries matching a pattern relative to the directory. Path.rglob(pattern) searches recursively, as though **/ were added before the pattern. Common wildcards include * for any number of characters, ? for one character, and [seq] for one character from a set. These are filesystem patterns, not regular expressions.
from pathlib import Path
from tempfile import TemporaryDirectory
with TemporaryDirectory() as temporary:
root = Path(temporary)
(root / "archive").mkdir()
(root / "report.csv").write_text("name\nGrace\n", encoding="utf-8")
(root / "archive" / "report.csv").write_text("name\nAda\n", encoding="utf-8")
(root / "notes.txt").write_text("draft\n", encoding="utf-8")
print("direct CSV files:")
for path in sorted(root.glob("*.csv")):
print(path.name)
print("CSV files anywhere below root:")
for path in sorted(root.rglob("*.csv")):
print(path.relative_to(root).as_posix())Choose a recursive pattern search
Complete the method name so both the root CSV and the nested CSV are included.
from pathlib import Path
from tempfile import TemporaryDirectory
with TemporaryDirectory() as temporary:
root = Path(temporary)
(root / "nested").mkdir()
(root / "root.csv").touch()
(root / "nested" / "inside.csv").touch()
matches = sorted(
path.relative_to(root).as_posix()
for path in root.("*.csv")
)
print(matches)
Make search results predictable and understand traversal limits
Directory iteration and glob results have no guaranteed order. Sort matches when output or tests need a stable order. Recursive searches may scan a large tree, so choose a narrow pattern and starting directory. pathlib globbing does not recurse into symbolic-link directories by default; also, filesystem scanning errors can be suppressed by glob methods, so do not use them when you must account for every traversal error.
Use path objects returned by the search directly with properties such as name and suffix or methods such as is_file(). For more control over a directory walk, including pruning directory names or receiving scan errors through a callback, consult Path.walk() in the official reference. The separate glob module remains a different API and is not covered by this lesson.
Compare paths by components, not characters
For ordinary new code, pathlib usually gives a clear path-object interface. The os.path module remains useful when an API expects string or bytes paths, or when maintaining code that already uses its functions. Choose commonpath() when you need the longest shared path made of complete components. Do not use commonprefix() for path relationships: it compares characters, can return a string that is not a valid common directory, and is deprecated in Python 3.15 in favor of commonpath().
import os
paths = [
os.path.join("workspace", "reports", "current.csv"),
os.path.join("workspace", "reports-old", "archive.csv"),
]
print(os.path.commonpath(paths))
related_paths = [
os.path.join("workspace", "reports", "current.csv"),
os.path.join("workspace", "reports", "archive.csv"),
]
print(os.path.commonpath(related_paths))The first pair shares only workspace as a complete directory component; the second pair also shares reports. commonpath() is a lexical path calculation, not a filesystem authorization check: it does not itself resolve symbolic links or prevent races between checking a path and opening it. Keep untrusted paths within a deliberately controlled filesystem workflow rather than treating a common-prefix result as a security boundary.
Find a common directory safely
Choose the os.path function that finds the longest shared path made of complete components, rather than comparing characters.
import os
paths = [
os.path.join("workspace", "reports", "current.csv"),
os.path.join("workspace", "reports-old", "archive.csv"),
]
shared =
print(shared)
Expand home-directory shortcuts explicitly
Python does not automatically expand a leading tilde or environment-variable syntax the way a shell might. Use Path.expanduser() when a user-entered path may begin with ~ or ~user. It returns a new Path; on Python 3.15, if the home directory cannot be resolved, Path.expanduser() raises RuntimeError, while os.path.expanduser() leaves an unexpanded path unchanged. These are explicit transformations, not validation that a destination is safe or exists.
from pathlib import Path
requested = Path("~/reports/summary.csv")
expanded = requested.expanduser()
print(expanded.is_absolute())
print(expanded.name)
print(expanded.parent.name)Expand environment variables in path strings
Python does not automatically substitute environment-variable references in a path string. os.path.expandvars() replaces references such as $NAME and ${NAME} with values from the current process environment; on Windows it also recognizes %NAME%. Malformed references and names that are not set remain unchanged. Unlike expanduser(), there is no direct pathlib.Path method for this transformation. expandvars() does not invoke a shell, create a directory, resolve symbolic links, check that the result exists, or make the result safe to use. If environment values influence a sensitive file operation, validate the resulting path separately.
import os
from unittest.mock import patch
# Keep this example independent of the machine running it.
with patch.dict(os.environ, {"FOXIPA_REPORTS_ROOT": "workspace/reports"}, clear=True):
requested = "$FOXIPA_REPORTS_ROOT/summary.csv"
expanded = os.path.expandvars(requested)
unresolved = os.path.expandvars("${FOXIPA_UNSET_SAMPLE}/summary.csv")
print(expanded)
print(unresolved)Distinguish absolute paths from resolved paths
Path.absolute() makes a path absolute without normalizing .. components or resolving symbolic links. Path.resolve() makes it absolute, resolves supported symbolic links, and removes .. components; its default strict=False mode can also form a resolved path when the final entry does not exist. For string-based APIs, os.path.abspath() normalizes a path but does not resolve links, whereas os.path.realpath() resolves them. Do not assume these operations are interchangeable, and do not treat resolving a path once as a substitute for safe filesystem operations later.
from pathlib import Path
from tempfile import TemporaryDirectory
with TemporaryDirectory() as temporary:
root = Path(temporary)
candidate = root / "folder" / ".." / "report.txt"
absolute = candidate.absolute()
resolved = candidate.resolve()
print(absolute.is_absolute())
print(".." in absolute.parts)
print(".." in resolved.parts)Choose the right relative-path operation
Path.relative_to(base) is a lexical relationship check: it produces a relative path only when base is a component prefix of the target, otherwise it raises ValueError. os.path.relpath(target, start=base) instead computes a route from base to target and can include .. components, even when the target is outside the starting directory. It does not check whether either path exists. A relative-path calculation is not an authorization boundary and does not by itself prevent traversal through symbolic links.
from pathlib import Path
import os
base = Path.cwd() / "project"
target = base.parent / "shared" / "data.csv"
try:
target.relative_to(base)
except ValueError:
print("target is not beneath base")
relative = Path(os.path.relpath(target, start=base))
print(relative.parts[0] == "..")
print(relative.name)Compare filesystem identity, not just path spelling
Two different path strings can refer to the same filesystem object, and two files with identical contents can still be different objects. Path.samefile() and os.path.samefile() ask the filesystem whether two existing paths identify the same file or directory; they can raise OSError if either path cannot be inspected. Use them when identity matters, not ordinary path equality or equal file contents. This check is a snapshot, not a security guarantee for a later operation.
import os
from pathlib import Path
from tempfile import TemporaryDirectory
with TemporaryDirectory() as temporary:
root = Path(temporary)
(root / "nested").mkdir()
original = root / "report.txt"
original.write_text("same data\n", encoding="utf-8")
alternate = os.path.join(str(root), "nested", "..", "report.txt")
copied = root / "copy.txt"
copied.write_bytes(original.read_bytes())
print(alternate != str(original))
print(original.samefile(alternate))
print(original.read_bytes() == copied.read_bytes())
print(os.path.samefile(original, copied))Normalize path syntax without resolving the filesystem
os.path.normpath() collapses redundant separators and lexical . and .. components; it does not inspect the filesystem or resolve symbolic links. That distinction matters because simplifying .. across a symbolic-link component can change which object a path would reach. Path.resolve() and os.path.realpath() perform filesystem-aware resolution, while normpath() is string-level normalization. os.path.normcase() is also platform-dependent: on Windows it normalizes case and separators, while on POSIX it returns the path unchanged, so it is not a universal way to identify files.
import os
raw = os.path.join("reports", ".", "2026", "..", "summary.csv")
normalized = os.path.normpath(raw)
expected = os.path.join("reports", "summary.csv")
print(normalized == expected)
print(".." in normalized.split(os.sep))Bridge text paths, bytes paths, and path-like objects
Modern code usually uses str paths or pathlib.Path objects. Many low-level os and os.path interfaces also accept bytes paths; a single os.path call must use one path type consistently, and a returned path/name keeps that type. Path accepts text paths, not bytes paths. os.fspath() obtains a PathLike object’s str or bytes representation, while os.fsencode() and os.fsdecode() convert using the filesystem encoding and error handler rather than assuming an arbitrary encoding such as UTF-8.
import os
from pathlib import Path
from tempfile import TemporaryDirectory
with TemporaryDirectory() as temporary:
path = Path(temporary) / "report.txt"
path.touch()
text_path = os.fspath(path)
bytes_path = os.fsencode(path)
bytes_name = os.path.basename(bytes_path)
restored_path = os.fsdecode(bytes_path)
print(type(text_path).__name__)
print(type(bytes_path).__name__)
print(type(bytes_name).__name__)
print(restored_path == text_path)Expand a home-directory shortcut
Choose the Path method that explicitly expands a leading ~ into the current user’s home directory.
from pathlib import Path
requested = Path("~/reports/summary.csv")
expanded = requested.()
print(expanded.is_absolute())
print(expanded.name)
Resolve dot-dot components
Choose the method that makes the path absolute and removes .. components, while resolving supported symbolic links.
from pathlib import Path
candidate = Path.cwd() / "folder" / ".." / "report.txt"
result = candidate.()
print(".." in result.parts)
Compute a relative route to an outside target
Choose the os.path function that computes a relative route from base to target even when target is not below base.
from pathlib import Path
import os
base = Path.cwd() / "project"
target = base.parent / "shared" / "data.csv"
relative = Path(os.path.(target, start=base))
print(relative.parts[0] == "..")
Check whether two paths identify the same file
Fill the expression with a filesystem identity check. The two path spellings may differ even though they identify the same file.
import os
from pathlib import Path
from tempfile import TemporaryDirectory
with TemporaryDirectory() as temporary:
root = Path(temporary)
(root / "nested").mkdir()
report = root / "report.txt"
report.write_text("data", encoding="utf-8")
alternate = os.path.join(str(root), "nested", "..", "report.txt")
same =
print(same)
Normalize path syntax lexically
Choose the function that collapses redundant separators and dot components without resolving symbolic links or checking whether the path exists.
import os
raw = os.path.join("reports", "2026", "..", "summary.csv")
normalized = os.path.(raw)
print(normalized == os.path.join("reports", "summary.csv"))
Preserve path representation types
Complete the call so basename receives the bytes path and the returned name is also bytes.
import os
encoded = os.fsencode("reports/summary.csv")
name = os.path.basename()
print(type(name).__name__)
Expand a variable inside a path string
Complete the call so the variable reference in requested is replaced with the value from the current process environment. The temporary mapping makes the example deterministic.
import os
from unittest.mock import patch
with patch.dict(os.environ, {"FOXIPA_EXPORT_ROOT": "workspace/exports"}, clear=True):
requested = "$FOXIPA_EXPORT_ROOT/daily.csv"
result =
print(result)
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.