Skip to content

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

FixDevs · (Updated: )

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

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)pathlib introduced via PEP 428. Core API only: Path, PurePath, glob, read_text/read_bytes. No __fspath__ protocol, so passing a Path to open() required wrapping in str().
  • Python 3.6 (Dec 2016) — PEP 519 added the os.fspath protocol. Path objects became transparently accepted by open(), os.path functions, and most stdlib filesystem APIs. This is the version that made pathlib actually pleasant to use.
  • Python 3.8 (Oct 2019) — added Path.link_to (later renamed) and the missing_ok=True argument to Path.unlink. Many examples that call Path.unlink(missing_ok=True) will TypeError on 3.7 and earlier.
  • Python 3.9 (Oct 2020) — added Path.with_stem to swap a file’s stem without touching the suffix, and Path.readlink to read a symlink target without resolving the full chain.
  • Python 3.10 (Oct 2021) — added the newline keyword to Path.read_text and Path.write_text, mirroring open(). Added Path.hardlink_to.
  • Python 3.12 (Oct 2023) — added Path.walk() (a generator equivalent to os.walk returning Path objects), and Path.relative_to gained a walk_up=True flag so Path('/a/b').relative_to('/a/c', walk_up=True) produces .. segments instead of raising.
  • Python 3.13 (Oct 2024)Path.glob and Path.rglob gained 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_sensitive flag added. Added Path.copy, Path.copytree, and Path.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 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.

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

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