Fix: Python pathlib Not Working — Path Object Errors, Joins, and Common Pitfalls
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. 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.
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.
For related Python issues, see Fix: Python Import Error and Fix: Python Decorator Not Working.
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.