skip to content

pathlib and Filesystem Paths

pathlib replaces string joins and os.path with an object that knows it is a path: join with /, inspect parts, glob a tree, read or write in one call. Resolving before trusting stops traversal bugs.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

4

How does pathlib.Path use the / operator to join paths, and what do .name, .stem and .parent return?

level: juniorimportance: must knowfreq 72%

answer

  1. Paths are objects, not strings
  2. One operator replaces os.path.join
  3. Division builds, properties take apart
  4. name minus one suffix is stem
  5. parent drops the last component

basics

~10 s

pathlib.Path overloads the division operator, so Path("exports") / "schedule.json" builds a new path object with the right separator. On that result .name is "schedule.json", .stem is "schedule", .suffix is ".json" and .parent is Path("exports").

solid answer

~40 s

`pathlib.Path` implements `__truediv__`, so `Path("exports") / "2026-03-01" / "schedule.json"` joins segments with the platform separator and returns a **new** immutable path — nothing is mutated, and a plain `str` may sit on either side of the operator. Decomposition is by property: `.name` is the final component, `.suffix` is the last dot-extension, `.stem` is `.name` minus that one suffix, `.parent` drops the last component, `.parts` is the whole tuple, and `.suffixes` lists every extension of a multi-dotted name. The `os.path` equivalents are `os.path.join`, `os.path.basename`, `os.path.splitext` and `os.path.dirname`, all of which operate on raw strings. All of these are pure string algebra — they never touch the disk. Because `Path` satisfies the `os.PathLike` protocol, you can pass it straight to `open()` or to any `os` function without calling `str()` on it.

code

pycon · 10 lines
pycon
>>> from pathlib import Path
>>> p = Path("exports") / "2026-03-01" / "schedule.json"
>>> p
PosixPath('exports/2026-03-01/schedule.json')
>>> p.name, p.stem, p.suffix
('schedule.json', 'schedule', '.json')
>>> p.parent
PosixPath('exports/2026-03-01')
>>> p.parts
('exports', '2026-03-01', 'schedule.json')

go deeper

for a junior

Be ready to build a path with / and name what .name, .stem, .suffix and .parent give back, without reaching for os.path.join or string concatenation. Knowing that a Path can go straight into open() is expected.

for a middle

Explain the mechanics: truediv returning a new immutable object, PurePath versus Path as the line between string algebra and syscalls, and the edge cases — one suffix only, dotfiles, and an absolute right-hand segment discarding the left.

for a senior

Show the judgement of when path handling is a correctness risk: lexical equality that does not mean same file, .parent leaving '..' in place, and joining a caller-supplied segment proving nothing about where the result lands.

for a principal

Own the convention across a codebase: paths as objects at every boundary rather than strings converted back and forth, str() pushed to the last possible moment, and PurePosixPath used so path handling can be tested on any host.

`pathlib` has been in the standard library since Python 3.4, and it exists because a filesystem path is not really a string: it has structure, its separator is platform-dependent, and almost every bug in hand-rolled path code comes from treating it as text. ### Pure paths and concrete paths `Path("exports")` returns a concrete path for the host you are running on — a `PosixPath` on Unix and macOS, a `WindowsPath` on Windows. Both inherit from `PurePath`, which holds every operation that is pure string algebra: joining, splitting, comparing, matching. `PurePosixPath` and `PureWindowsPath` let you manipulate a foreign-flavoured path on any host, which is the only sane way to parse a Windows path inside a test running on Linux. The split matters because it tells you which calls hit the filesystem: anything on `PurePath` cannot, anything added by `Path` (`exists`, `is_file`, `read_text`, `mkdir`, `glob`, `resolve`) can. ### Joining with / `PurePath.__truediv__` is why `/` works, and `__rtruediv__` is why `"exports" / Path("schedule.json")` works too. Each `/` returns a brand-new path object; paths are immutable and hashable, so you can use them as dict keys and share them freely. `joinpath("a", "b")` is the method form, useful when the segments arrive as a list you want to splat. One rule surprises people, and it matches `os.path.join`: **an absolute right-hand segment discards everything on the left.** `Path("/srv/data") / "/etc/hosts"` is `Path("/etc/hosts")`, not a path under `/srv/data`. So concatenating a caller-supplied segment does not by itself keep the result inside your base directory. Joining normalises separators but not semantics: `Path("a//b")` and `Path("a/b/")` both collapse to `a/b`, so `Path("a/b/").name` is `"b"` and not `""`. But `..` and `.` are left alone — `Path("a/b/..").parent` is `Path("a/b")`, not `Path(".")`. Only `resolve()` eliminates `..`. ### Decomposing - `.parts` — the components as a tuple, e.g. `('exports', '2026-03-01', 'schedule.json')`. - `.name` — the last component. It is `''` for a root path such as `Path("/")`. - `.suffix` — the **last** dot-extension only. For `schedule.tar.gz` that is `'.gz'`. - `.suffixes` — the full list, `['.tar', '.gz']`. - `.stem` — `.name` with exactly one suffix removed, so `'schedule.tar'` for that file. - `.parent` — the path minus its last component; it never climbs above what it was given, so `Path("schedule.json").parent` is `Path(".")` and `Path(".").parent` is `Path(".")`. - `.parents` — a lazy sequence of ancestors, and the classic off-by-one lives here: `parents[0]` is the parent, not the path itself. Since 3.10 it accepts slices and negative indices. - `.anchor`, `.drive`, `.root` — the platform prefix, mostly interesting on Windows. A leading dot is not a suffix: `Path(".gitignore").suffix` is `''` and its `.stem` is `'.gitignore'`. That single rule fixes the most common naive `splitext` bug. For rewriting a name there are three helpers rather than string surgery: `with_name`, `with_suffix` (replaces the last one, or appends if there is none) and `with_stem` (added in 3.9). ### Equality is lexical, not filesystem truth Two paths compare equal when their string forms match under their flavour — case-sensitively on POSIX, case-insensitively on Windows — and paths of different flavours are never equal. `Path("a/b")` and `Path("a/c/../b")` are different objects even though they name one file: redundant separators and single-dot components are dropped when the path is parsed, but `..` never is. `==` never performs a syscall. To ask whether two paths point at the same file, call `Path.samefile()`, which stats both and compares device and inode. ### Interoperating with older APIs `Path` implements the `os.PathLike` protocol, so `open(p)`, `os.stat(p)`, `os.scandir(p)` and essentially every stdlib function that takes a filename accepts one directly; `os.fspath(p)` is the explicit conversion to `str`. Only a third-party library that predates that protocol, or code that does string formatting on the value, needs `str(p)` — and `as_posix()` is the right call when you need forward slashes in output regardless of host.

  • What does Path("schedule.tar.gz").stem return, and why?
    `'schedule.tar'`. `.suffix` is only the last dot-extension, `'.gz'`, and `.stem` is `.name` with that single suffix removed. The full list is `.suffixes`, which gives `['.tar', '.gz']`. A leading dot is deliberately not a suffix, so `Path('.gitignore').suffix` is `''` and its `.stem` is the whole name — which is exactly the case a naive `os.path.splitext` wrapper usually gets wrong.
  • Does comparing two Path objects with == touch the filesystem?
    No. Equality and hashing are lexical, on the string form under the path flavour: `Path('a/b')` is not equal to `Path('a/c/../b')`, even though both name the same file, because `..` is never collapsed at parse time. Paths of different flavours never compare equal. Comparison is case-sensitive on POSIX and case-insensitive on Windows. To ask whether two paths refer to the same file, call `Path.samefile()`, which stats both and compares device and inode.
  • What happens when you join an absolute segment onto a Path with /?
    The left operand is thrown away: `Path('/srv/data') / '/etc/hosts'` is `Path('/etc/hosts')`, matching `os.path.join`'s rule. So building a path by concatenating a caller-supplied segment gives you no guarantee that the result sits under your base directory — that has to be established afterwards, by resolving the candidate and comparing it against the resolved base.
  • When do you still need str() around a Path?
    Rarely. `Path` implements the `os.PathLike` protocol, so `open()`, the `os` and `shutil` functions and most of the stdlib accept it directly, and `os.fspath()` is the explicit conversion. You need a string for old libraries that predate the protocol, and for string formatting or serialisation — where `as_posix()` is usually the better choice, because it gives forward slashes regardless of host.

A string path is a sentence about a file; a Path object is a parsed sentence — the separator is punctuation the object already understands, so you ask for the last word rather than counting characters.

saying these in an interview costs you the question

  • Thinks / mutates the left-hand Path in place
  • Says .suffix of archive.tar.gz is '.tar.gz'
  • Builds paths with string concatenation and a hardcoded separator
  • Believes == on two Paths asks the filesystem
  • Assumes .parent collapses '..' segments
  • Calls str() on every Path before passing it to open()

context

open as a page

What is the difference between Path.glob and Path.rglob in pathlib, and what do they return?

level: middleimportance: should knowfreq 55%

basics

~10 s

Both return a lazy generator of Path objects, not a list. Path.glob(pattern) matches the pattern against entries under that directory; Path.rglob(pattern) is shorthand for glob("**/" + pattern), so it searches every subdirectory recursively.

open as a page

What do the parents and exist_ok arguments to pathlib's Path.mkdir() do?

level: middleimportance: should knowfreq 50%

basics

~20 s

parents=True creates every missing intermediate directory instead of raising FileNotFoundError. exist_ok=True suppresses FileExistsError when the target directory already exists. Both default to False, so a bare mkdir() demands that the parent exists and the target does not.

open as a page

In pathlib, how does Path.resolve() differ from Path.absolute(), and how do you check a path stays under a base directory?

level: seniorimportance: should knowfreq 44%

basics

~10 s

Path.absolute() only prepends the current working directory; it leaves symlinks and '..' in place. Path.resolve() also follows symlinks and eliminates '..'. Containment means resolving both the base and the candidate, then calling candidate.is_relative_to(base).

open as a page