pathlib Path.resolve() Does Not Detect a Symlink Loop by Default (tested)
A symlink that points at itself, or at a chain that loops back on itself, is a real and not-uncommon mistake. We measured what pathlib.Path.resolve() does with one, and the default behaviour doesn't raise or warn — it just hands back the loop.
1. resolve() on a self-referential symlink
from pathlib import Path
import os
os.symlink("/tmp/loopdir/self", "/tmp/loopdir/self")
p = Path("/tmp/loopdir/self")
print("resolve() default:", p.resolve())
print("resolve() == original:", p.resolve() == p)
resolve() default: /tmp/loopdir/self
resolve() == original: True
Measured: resolve() is documented to make a path absolute and follow symlinks. Here it returns the exact same path it was given, with no exception, no change, and no indication that following the link forever is impossible. A caller checking "did this resolve to something different / outside my base dir" would see nothing wrong.
2. The failure only shows up when you use the result
resolved = p.resolve()
try:
resolved.read_text()
except OSError as e:
print("reading the resolved path raises:", e)
try:
p.resolve(strict=True)
except OSError as e:
print("resolve(strict=True) raises:", e)
reading the resolved path raises: [Errno 40] Too many levels of symbolic links: '/tmp/loopdir/self'
resolve(strict=True) raises: [Errno 40] Too many levels of symbolic links: '/tmp/loopdir/self'
Measured: the loop is real and the OS does reject it (ELOOP) — but only once something actually opens or stats the path. resolve()'s default strict=False mode never touches the filesystem that deeply, so it returns a path that looks fully resolved and then blows up two steps later, far from where the actual problem is.
3. exists() on the same loop also stays quiet
print("is_symlink:", p.is_symlink())
print("exists():", p.exists())
is_symlink: True
exists(): False
Measured: exists() catches the OSError internally and reports False — the same answer it gives for a path that was never created at all (see our dangling-symlink guide for that case specifically; a loop and a missing target both end up as plain False, with no way to tell them apart from exists() alone). Only stat() or resolve(strict=True) surfaces the real ELOOP error.
4. Fix: use strict=True when you need to trust the result
try:
real = p.resolve(strict=True)
except OSError as e:
print("not a usable path:", e)
not a usable path: [Errno 40] Too many levels of symbolic links: '/tmp/loopdir/self'
Measured: passing strict=True makes resolve() actually stat each component, which both raises FileNotFoundError for missing paths (not just symlink loops) and raises OSError for loops, instead of silently returning something that fails later.
Frequently asked questions
Does Path.resolve() detect a symlink loop?
Not by default -- we measured resolve() with strict=False (the default) returning the loop path unchanged, with no exception. Only resolve(strict=True) raises OSError for it.
Why does resolve() return the same path instead of raising?
The default strict=False mode resolves components lexically and only partially touches the filesystem, so it doesn't fully follow the symlink chain far enough to detect it never terminates.
What error does a symlink loop actually raise?
We confirmed OSError: [Errno 40] Too many levels of symbolic links (ELOOP) -- raised by stat(), read_text(), or resolve(strict=True), but not by resolve(strict=False) or exists().
Does exists() distinguish a symlink loop from a missing file?
No -- we measured both cases returning False from exists(), with the underlying OSError caught and discarded internally either way.
How do I safely resolve a path that might be a symlink loop?
We confirmed wrapping Path.resolve(strict=True) in a try/except OSError catches the loop immediately, instead of getting a path back that fails confusingly wherever it's used next.
Written with AI assistance. Measured on 2026-10-04 with Python 3.13.5's built-in pathlib: created a symlink pointing at itself (a self-referential loop), then compared Path.resolve() with its default strict=False against strict=True, plus Path.exists() and Path.stat() on the same path. PlainHeaders is our extension; Python's pathlib module is built in.