skip to content

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%

answer

  1. Only one of them touches the disk
  2. Symlinks decide what '..' means
  3. Absolute is not the same as normalised
  4. Resolve both sides before comparing
  5. is_relative_to compares components lexically

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).

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
pycon
>>> 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

for a junior

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.

for a middle

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().

for a senior

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.

for a principal

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 ~

context