Skip to content

Fix: Python pathlib Not Working — Path Object Errors, Joins, and Common Pitfalls

FixDevs ·

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 + '/' + filename

Or 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 there

Or 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 structure

Why This Happens

pathlib.Path is not a string — it’s an object with its own API. Most errors come from:

  • String operations on Path objectsPath doesn’t support + for concatenation. Use / operator or .joinpath().
  • Wrong method name — the method is .read_text() or .read_bytes(), not .read().
  • glob() pattern mismatchglob('*.conf') only matches the immediate directory. For recursive search, use rglob('*.conf') or glob('**/*.conf').
  • Mixing str and Path — some functions accept both, others require one type. Unexpected behavior comes from implicit type mismatch.
  • Relative vs absolute pathsPath('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 supported

Joining 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 exists

Fix 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 Windows

Common 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. Add if 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() # False

Script-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 missing

Fix 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 listpath.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 path

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

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles