Fix: Python pathlib Not Working — Path Object Errors, Joins, and Common Pitfalls
Part of: Python Errors
Quick Answer
How to fix Python pathlib issues — TypeError with string concatenation, path joining, glob patterns, reading files, cross-platform paths, and migrating from os.path.
The Problem
Python’s pathlib throws a TypeError when concatenating paths:
from pathlib import Path
base = Path('/home/user/project')
filename = 'config.json'
# TypeError: unsupported operand type(s) for +: 'PosixPath' and 'str'
full_path = base + '/' + filenameOr a glob() call returns nothing despite matching files existing:
config_dir = Path('/etc/myapp')
configs = list(config_dir.glob('*.conf'))
# Returns [] — but the files are definitely thereOr reading a file fails with an unexpected error:
path = Path('data/input.txt')
content = path.read() # AttributeError: 'PosixPath' object has no attribute 'read'Or cross-platform code breaks on Windows:
path = Path('C:/Users/alice') / 'documents'
# Works on Windows, but breaks on Linux with an unexpected path structureWhy This Happens
pathlib.Path is not a string — it’s an object with its own API. The mental shift is the source of most early errors. Many tutorials and legacy codebases mix os.path (string-based) with pathlib (object-based) freely, and the boundary between them is where bugs hide. A Path looks like a string in repr() and prints like a string in str(), but it does not behave like one: it does not support +, slicing, .startswith() for prefix matching in the way you might want, or .endswith() for extension matching (use .suffix instead).
There’s a second layer of complexity around filesystem semantics. Path('file.txt').exists() checks the current working directory at the moment of the call, not at import time. Path.cwd() reflects whatever directory the interpreter was launched from, which is usually not the directory containing your script. Code that “works on my machine” frequently fails on a server simply because python /opt/app/script.py is launched from / rather than /opt/app.
Most errors come from:
- String operations on Path objects —
Pathdoesn’t support+for concatenation. Use/operator or.joinpath(). - Wrong method name — the method is
.read_text()or.read_bytes(), not.read(). glob()pattern mismatch —glob('*.conf')only matches the immediate directory. For recursive search, userglob('*.conf')orglob('**/*.conf').- Mixing
strandPath— some functions accept both, others require one type. Unexpected behavior comes from implicit type mismatch. - Relative vs absolute paths —
Path('file.txt')is relative to the current working directory, which may not be what you expect inside a script.
Version History That Changes the Failure Mode
pathlib has grown almost every release since it landed. If you copy code from a Python 3.12 doc page and run it on a Python 3.8 server, methods will be missing and the traceback will not explain why.
- Python 3.4 (Mar 2014) —
pathlibintroduced via PEP 428. Core API only:Path,PurePath,glob,read_text/read_bytes. No__fspath__protocol, so passing aPathtoopen()required wrapping instr(). - Python 3.6 (Dec 2016) — PEP 519 added the
os.fspathprotocol.Pathobjects became transparently accepted byopen(),os.pathfunctions, and most stdlib filesystem APIs. This is the version that madepathlibactually pleasant to use. - Python 3.8 (Oct 2019) — added
Path.link_to(later renamed) and themissing_ok=Trueargument toPath.unlink. Many examples that callPath.unlink(missing_ok=True)willTypeErroron 3.7 and earlier. - Python 3.9 (Oct 2020) — added
Path.with_stemto swap a file’s stem without touching the suffix, andPath.readlinkto read a symlink target without resolving the full chain. - Python 3.10 (Oct 2021) — added the
newlinekeyword toPath.read_textandPath.write_text, mirroringopen(). AddedPath.hardlink_to. - Python 3.12 (Oct 2023) — added
Path.walk()(a generator equivalent toos.walkreturningPathobjects), andPath.relative_togained awalk_up=Trueflag soPath('/a/b').relative_to('/a/c', walk_up=True)produces..segments instead of raising. - Python 3.13 (Oct 2024) —
Path.globandPath.rglobgained native support for full recursive**semantics matching shell behavior (previously**only matched directories, not files at every level in the way you’d expect).case_sensitiveflag added. AddedPath.copy,Path.copytree, andPath.move.
Run python -V before assuming a method exists.
Fix 1: Use / to Join Paths, Not +
The Path object overloads the / operator for path joining:
from pathlib import Path
base = Path('/home/user/project')
# WRONG — TypeError
full_path = base + '/config.json'
# CORRECT — use / operator
full_path = base / 'config.json'
# PosixPath('/home/user/project/config.json')
# Multiple segments at once
full_path = base / 'config' / 'settings.json'
# Or use joinpath()
full_path = base.joinpath('config', 'settings.json')
# Joining with a variable
filename = 'config.json'
full_path = base / filename # Works — Path / str is supportedJoining with a string that starts with / replaces the base:
base = Path('/home/user')
# GOTCHA: an absolute path segment replaces the entire path
result = base / '/etc/config'
# PosixPath('/etc/config') — base is discarded!
# To append a relative path from a string that may have a leading slash:
segment = '/config/settings.json'
result = base / segment.lstrip('/')
# PosixPath('/home/user/config/settings.json')Fix 2: Use the Correct Read/Write Methods
Path has dedicated methods for reading and writing files — the method is not .read():
from pathlib import Path
path = Path('data/notes.txt')
# WRONG
content = path.read() # AttributeError
# CORRECT
content = path.read_text() # Read as string (UTF-8 by default)
content = path.read_text(encoding='utf-8') # Explicit encoding
raw = path.read_bytes() # Read as bytes
# Writing
path.write_text('Hello, world\n')
path.write_bytes(b'\x00\x01\x02')
# For appending or more control, use open()
with path.open('a') as f:
f.write('Appended line\n')
# open() on a Path works just like the built-in open()
with path.open('r', encoding='utf-8') as f:
for line in f:
print(line.strip())Creating directories:
# Create a single directory
Path('output').mkdir()
# Create all missing parent directories
Path('output/data/processed').mkdir(parents=True, exist_ok=True)
# exist_ok=True: no error if directory already existsFix 3: Fix glob() Pattern Issues
glob() only searches the immediate level by default. For recursive searches:
from pathlib import Path
project = Path('/home/user/project')
# Only searches direct children
configs = list(project.glob('*.py'))
# WRONG — this does NOT work recursively in pathlib
# (unlike shell globbing where ** expands recursively)
configs = list(project.glob('*/*.py')) # Only one level deep
# CORRECT — use ** for recursive search
all_python = list(project.glob('**/*.py')) # All .py files recursively
all_python = list(project.rglob('*.py')) # Equivalent — rglob adds ** prefix
# Case sensitivity: on Linux, glob is case-sensitive
# 'README.md' won't match '*.MD' on Linux, but will on WindowsCommon glob patterns:
# All files in the directory (not recursive)
files = list(path.glob('*'))
# All files of a type recursively
py_files = list(path.rglob('*.py'))
# Match multiple extensions
for f in path.glob('**/*'):
if f.suffix in ('.jpg', '.png', '.gif'):
print(f)
# All directories
dirs = [p for p in path.glob('**/') if p.is_dir()]
# Files matching a prefix
logs = list(path.glob('access_*.log'))Note:
glob('**')matches files AND directories. Addif p.is_file()to filter.
Fix 4: Convert Between Path and str
Some functions require a string, not a Path. Know when to convert:
import subprocess
from pathlib import Path
import os
script = Path('/home/user/scripts/deploy.sh')
# Most built-in functions accept Path directly (Python 3.6+)
with open(script) as f: # Works — open() accepts Path
pass
os.path.exists(script) # Works — os.path functions accept Path
os.listdir(script.parent) # Works
# For functions that require a string, use str()
subprocess.run([str(script)]) # subprocess prefers strings
subprocess.run(['bash', str(script)]) # Explicit conversion
# Or use os.fspath() — the proper way to convert
subprocess.run([os.fspath(script)])
# Check if something is already a Path
isinstance(script, Path) # True
isinstance(str(script), str) # True
# Accept both str and Path in your own functions
from pathlib import Path
from typing import Union
def process_file(path: Union[str, Path]) -> str:
path = Path(path) # Normalize — always convert to Path at the start
return path.read_text()Fix 5: Understand Relative vs Absolute Paths
Path('file.txt') is relative to the current working directory, which may not be the script’s location:
from pathlib import Path
# Relative — depends on where you run the script from
config = Path('config.json')
# If you run from /home/user: resolves to /home/user/config.json
# If you run from /etc: resolves to /etc/config.json
# Absolute — always the same
config = Path('/etc/myapp/config.json')
# Get the script's directory (most reliable for data files next to the script)
script_dir = Path(__file__).parent
config = script_dir / 'config.json'
# Always resolves relative to the script's location, regardless of cwd
# Resolve a relative path to absolute
relative = Path('data/input.txt')
absolute = relative.resolve()
# PosixPath('/home/user/project/data/input.txt') based on current cwd
# Check if a path is absolute
Path('/etc/hosts').is_absolute() # True
Path('data/file.txt').is_absolute() # FalseScript-relative paths are the safest approach for config files and data bundled with your code:
# config.py
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent # Two levels up from this file
DATA_DIR = BASE_DIR / 'data'
CONFIG_FILE = BASE_DIR / 'config' / 'settings.json'
LOG_DIR = BASE_DIR / 'logs'
LOG_DIR.mkdir(exist_ok=True) # Create at startup if missingFix 6: Path Properties and Inspection Methods
Path exposes file metadata and path components as properties:
from pathlib import Path
p = Path('/home/user/documents/report.final.pdf')
# Path components
p.name # 'report.final.pdf' — file name with extension
p.stem # 'report.final' — name without last extension
p.suffix # '.pdf' — last extension (with dot)
p.suffixes # ['.final', '.pdf'] — all extensions
p.parent # PosixPath('/home/user/documents')
p.parents[0] # PosixPath('/home/user/documents')
p.parents[1] # PosixPath('/home/user')
p.parts # ('/', 'home', 'user', 'documents', 'report.final.pdf')
p.anchor # '/' — root on Unix, drive on Windows
# Existence and type checks
p.exists() # True/False
p.is_file() # True if exists and is a file
p.is_dir() # True if exists and is a directory
p.is_symlink() # True if a symbolic link
# File metadata
stat = p.stat()
stat.st_size # Size in bytes
stat.st_mtime # Last modified time (Unix timestamp)
import datetime
modified = datetime.datetime.fromtimestamp(p.stat().st_mtime)
# Changing extensions
new_path = p.with_suffix('.txt')
# PosixPath('/home/user/documents/report.final.txt')
new_path = p.with_name('backup.pdf')
# PosixPath('/home/user/documents/backup.pdf')
new_path = p.with_stem('report-v2') # Python 3.9+
# PosixPath('/home/user/documents/report-v2.pdf')Fix 7: Cross-Platform Path Handling
pathlib handles OS differences automatically, but some patterns still cause issues:
from pathlib import Path, PurePosixPath, PureWindowsPath
# Path() creates the right type for the current OS
# On Linux: PosixPath
# On Windows: WindowsPath
# For cross-platform code, avoid hardcoded separators
# WRONG — hardcoded separator
path = 'data' + '/' + 'file.txt' # Breaks on Windows
# CORRECT — pathlib handles it
path = Path('data') / 'file.txt'
# Testing Windows paths on Linux (or vice versa)
# Use Pure paths for manipulation without OS calls
win_path = PureWindowsPath('C:/Users/alice/documents')
win_path.parts # ('C:\\', 'Users', 'alice', 'documents')
posix_path = PurePosixPath('/home/alice/documents')
posix_path.parts # ('/', 'home', 'alice', 'documents')
# Convert a Windows path to POSIX for display
win = PureWindowsPath('C:/Users/alice')
win.as_posix() # 'C:/Users/alice'Home directory expansion:
# ~ expansion
home = Path.home()
# PosixPath('/home/user') on Linux, WindowsPath('C:/Users/user') on Windows
config = Path('~/.config/myapp/settings.json').expanduser()
# PosixPath('/home/user/.config/myapp/settings.json')
# Current directory
cwd = Path.cwd()Migrating from os.path to pathlib
Common os.path patterns and their pathlib equivalents:
import os
from pathlib import Path
# os.path.join
os.path.join('/home/user', 'docs', 'file.txt')
Path('/home/user') / 'docs' / 'file.txt'
# os.path.exists
os.path.exists('/etc/hosts')
Path('/etc/hosts').exists()
# os.path.isfile / isdir
os.path.isfile(path)
Path(path).is_file()
os.path.isdir(path)
Path(path).is_dir()
# os.path.basename / dirname
os.path.basename('/home/user/file.txt') # 'file.txt'
Path('/home/user/file.txt').name # 'file.txt'
os.path.dirname('/home/user/file.txt') # '/home/user'
Path('/home/user/file.txt').parent # PosixPath('/home/user')
# os.path.splitext
os.path.splitext('file.tar.gz') # ('file.tar', '.gz')
p = Path('file.tar.gz')
p.stem, p.suffix # ('file.tar', '.gz')
# os.path.abspath
os.path.abspath('relative/path')
Path('relative/path').resolve()
# os.makedirs
os.makedirs('a/b/c', exist_ok=True)
Path('a/b/c').mkdir(parents=True, exist_ok=True)
# Reading and writing
with open('/tmp/file.txt', 'r') as f:
content = f.read()
content = Path('/tmp/file.txt').read_text()
# Listing directory contents
os.listdir('/home/user')
list(Path('/home/user').iterdir())
# Walk directory tree
for root, dirs, files in os.walk('/home/user'):
for file in files:
print(os.path.join(root, file))
for file in Path('/home/user').rglob('*'):
if file.is_file():
print(file)Still Not Working?
Path is not accepted by a third-party library — older libraries predate Python 3.6’s os.fspath protocol and may not accept Path. Convert with str(path) when passing to these functions.
glob() returns an iterator, not a list — path.glob('*.py') is a generator. If you iterate it once (e.g., to check if it’s empty), it’s exhausted. Convert to list() first if you need to reuse it:
py_files = list(Path('.').glob('*.py'))
if py_files:
print(f"Found {len(py_files)} files")read_text() raises UnicodeDecodeError — the default encoding on Windows may not be UTF-8. Always specify encoding explicitly:
content = path.read_text(encoding='utf-8')Comparing paths — two Path objects are equal if they represent the same path string, but not if one is resolved and one is not:
Path('file.txt') == Path('./file.txt') # False — different strings
Path('file.txt').resolve() == Path('./file.txt').resolve() # True — same absolute pathPath in type hints — use pathlib.Path for type hints in Python 3.9+ or from __future__ import annotations. For functions that accept both strings and paths, annotate with Union[str, Path] or the newer str | Path.
glob('**/*.py') misses top-level files on Python below 3.13 — until Python 3.13, the ** segment only expanded to directory levels, so files in the root of the searched directory were skipped unless you ran glob('*.py') and glob('**/*.py') separately or used rglob('*.py'). Upgrade to 3.13 or use rglob.
Path.resolve() raises on missing path in older Python — before 3.6, resolve() raised FileNotFoundError for any non-existent path. Pass strict=False (the default since 3.6) or upgrade to skip the check.
Windows long path limit — Path operations on Windows fail silently for paths longer than 260 characters unless long-path support is enabled in the registry, or you prefix the path with \\?\. Use os.path.abspath(p) with the prefix when constructing paths inside deeply nested build outputs.
For related Python issues, see Fix: Python Import Error (Circular), Fix: Python Decorator Not Working, Fix: Python virtualenv Wrong Python, and Fix: Python TypeError NoneType Not Subscriptable.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Python contextmanager Not Working — GeneratorExit, Missing yield, or Cleanup Not Running
How to fix Python context manager issues — @contextmanager generator, __enter__ and __exit__, exception handling inside with blocks, async context managers, and common pitfalls.
Fix: Python Protocol Not Working — Type Checker Rejects Compatible Class, runtime_checkable Fails, or Protocol Not Recognized
How to fix Python Protocol class issues — structural subtyping vs nominal typing, runtime_checkable, Protocol inheritance, TypeVar constraints, and common mypy/pyright errors with Protocol.
Fix: Python asyncio.gather Not Handling Errors — Exceptions Swallowed or All Tasks Cancelled
How to fix asyncio.gather error handling — return_exceptions parameter, partial failures, task cancellation propagation, TaskGroup alternatives, and exception isolation patterns.
Fix: Python Decorator Not Working — Function Signature Lost or Decorator Not Applied
How to fix Python decorator issues — functools.wraps, decorator factories with arguments, class decorators, stacking order, async function decorators, and common pitfalls.