In pathlib, how does Path.resolve() differ from Path.absolute(), and how do you check a path stays under a base directory?
answer
- Only one of them touches the disk
- Symlinks decide what '..' means
- Absolute is not the same as normalised
- Resolve both sides before comparing
- is_relative_to compares components lexically
basics
~10 sPath.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).
solid answer
~50 s`Path.absolute()` makes a path absolute by joining it to the current working directory and stops there — no normalisation, no symlink resolution, so `..` survives in `.parts`. `Path.resolve()` touches the filesystem: it follows every symlink along the way and eliminates `..` **after** that resolution, which is the only correct order, and it is the only pathlib method that removes `..` at all. Since 3.6 `resolve()` is non-strict by default, so a path that does not exist yet still resolves; `strict=True` raises `OSError` (a `FileNotFoundError`) instead. To confirm a candidate lives under a base directory, resolve both and then use `candidate.is_relative_to(base)` (3.9+), or `relative_to()` inside a `try` for the `ValueError`. `is_relative_to` alone is purely lexical and proves nothing about an unresolved path, and even after resolving, the check and the later open are two separate moments.
code
pycon · 8 lines>>> from pathlib import Path
>>> p = Path("exports/2026-03-01/../2026-03-02")
>>> ".." in p.absolute().parts
True
>>> ".." in p.resolve().parts
False
>>> p.resolve().name
'2026-03-02'go deeper
Recall that resolve() gives you an absolute, symlink-free path with '..' removed, and that absolute() only glues on the current working directory. Know that resolve() does not complain about a file that does not exist yet.
Explain the mechanics: why '..' must be interpreted after symlinks, what strict=True changes, and that os.path.abspath is lexical while os.path.realpath is the true counterpart of resolve().
Demonstrate the production judgement: resolve base and candidate before comparing, use is_relative_to() knowing it is lexical, and be able to name the check-then-use window that remains and what actually narrows it.
Own the interface decision rather than the check: whether callers hand you path segments at all, or an identifier you map to a filename, and where in the system normalisation happens so it is done once instead of at every call site.
These two calls look interchangeable and are not: one is string algebra with the working directory bolted on, the other is a filesystem operation. ### absolute() versus resolve() `Path.absolute()` returns the path joined to `Path.cwd()` if it is relative, and otherwise returns it unchanged. It performs no normalisation whatsoever: `Path("exports/a/../b").absolute()` still has `'..'` sitting in its `.parts`, and a symlink in the middle is still a symlink. It is cheap and it never fails on a missing file. `Path.resolve()` does three things: makes the path absolute, follows every symlink encountered along it, and eliminates `..` components. It is the only pathlib method that removes `..`, and the order matters enormously — `a/link/..` is not `a` when `link` points somewhere else, so any purely lexical collapse of `..` can produce a path that names a different file than the one the operating system would reach. `resolve()` gets that right because it asks the filesystem; a symlink cycle surfaces as an `OSError`. Since Python 3.6 `resolve()` is non-strict by default, which is what makes it usable on a path you are about to create: missing trailing components are simply carried through. `resolve(strict=True)` raises `OSError` — concretely a `FileNotFoundError` — when the path does not exist, which is the right choice when you are validating something that is supposed to be there already. The `os.path` mapping is worth holding in your head, because the naming is a trap: `os.path.abspath` is **not** `resolve()`. `abspath` is `normpath(join(getcwd(), p))`, and `normpath` collapses `..` lexically without consulting the filesystem — so it is `absolute()` plus a lexical squash, and it can lie in the presence of symlinks. The true analogue of `resolve()` is `os.path.realpath`. Neither expands `~`; that is `Path.expanduser()`, and `resolve()` will not do it for you. ### Establishing containment The question "is this path inside that directory?" comes up whenever a path component arrives from outside the process — a request parameter, a config entry, an archive member name. Two facts make the naive version wrong. First, joining does not confine: `Path("/srv/exports") / "../../etc/hosts"` is a path that reaches outside, and an absolute segment discards the base entirely. Second, `is_relative_to()` — added in 3.9 — is a **lexical** comparison of components; it does not resolve anything, so asking it about an unnormalised candidate answers the wrong question. The sound shape is therefore: resolve the base once, join the untrusted segment, resolve the candidate, and only then compare. ```python base = Path("/srv/exports").resolve() candidate = (base / user_segment).resolve() if not candidate.is_relative_to(base): raise ValueError("outside base") ``` `relative_to()` inside a `try/except ValueError` is the pre-3.9 spelling of the same test and additionally hands you the relative remainder. Note that 3.12 added `relative_to(walk_up=True)`, which permits `..` in the *result*; that is for computing a relative link between two known paths, and it is precisely what you do not want in a containment check. ### What the check still does not give you Resolving and comparing is a decision made at one instant; opening the file happens at another. Between those two moments a component of the path can be replaced with a symlink pointing elsewhere, and your resolved answer is stale. That check-then-use window is inherent to any path-based validation. Narrowing it means holding a descriptor rather than re-deriving a name — opening the directory once and working relative to it via the `os` module's `dir_fd` support, or opening with `os.O_NOFOLLOW` — and structurally, not accepting a path segment at all: take an identifier and look the filename up yourself. The security taxonomy around this belongs elsewhere; what matters here is that `resolve()` normalises, it does not lock. Two smaller points round it out. `Path.samefile()` answers "same file?" by comparing stat results rather than strings, which is the honest test when both paths already exist. And `Path.exists()` gained `follow_symlinks=` in 3.12, so you can ask about a dangling symlink itself instead of its unreachable target.
- Why is collapsing '..' lexically not equivalent to what resolve() does?Because `..` is interpreted by the kernel *after* symlinks are followed. If `exports/link` is a symlink to `/var/archive`, then `exports/link/..` is `/var`, not `exports`. A lexical collapse — what `os.path.normpath`, and therefore `os.path.abspath`, performs — answers `exports` and names a different file than the operating system would reach. `resolve()` and `os.path.realpath` ask the filesystem, so they get the order right.
- What does resolve(strict=True) change?It requires the path to exist. With the default `strict=False` — the default since 3.6 — missing trailing components are carried through, which is what lets you resolve a file you are about to create. With `strict=True` a non-existent path raises `OSError`, concretely a `FileNotFoundError`. Use strict resolution when you are validating something that must already be there, and non-strict when you are computing a destination.
- Once resolve() and is_relative_to() agree the path is inside the base, is the operation safe?No — the check and the subsequent open are two separate moments, and a path component can be swapped for a symlink in between, which leaves your resolved answer stale. Narrowing that window means holding a descriptor instead of re-deriving a name: open the base directory once and work relative to it, or open with `os.O_NOFOLLOW`. Structurally the stronger design is not to accept a path segment at all, but an identifier you map to a filename yourself.
saying these in an interview costs you the question
- Treats os.path.abspath as an equivalent of resolve()
- Collapses '..' with string operations before checking
- Calls is_relative_to() on unresolved paths
- Assumes resolve() raises when the path is missing
- Thinks joining onto a base directory confines the result
- Believes resolve() expands a leading ~