AlgoMaster Logo

Working with Paths

Medium Priority20 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

Before opening a file, you need a path that points to it, and getting that path right is harder than it looks. The separator differs between operating systems, relative paths depend on where the program was started, user inputs often arrive with shortcuts like ~ or .. that have to be expanded, and string concatenation makes a mess of all of it. This lesson covers the building blocks: how Python represents paths, the functions in os.path for joining and splitting them, the common anchor points (home, current directory, temp), and the modern pathlib.Path alternative.

Why Paths Are Tricky

The naive approach is to glue strings together with whatever separator you happen to type:

On macOS and Linux that string works as a path. On Windows the native separator is \, and although most Windows APIs also accept /, mixing styles is fragile, especially when the path includes a drive letter or a UNC share. The same code can break when one of the components ends with a separator ("exports/") or starts with one ("/orders.csv").

The standard library handles the platform differences. The os.path submodule (legacy, string-based) and pathlib.Path (modern, object-based) both pick the platform separator and handle the edge cases consistently. Whichever you use, the rule is the same: don't concatenate path strings by hand.

The lesson uses os.path for most examples because that's the older API present in lots of existing code. pathlib does the same jobs with a friendlier interface and is the better choice for new code.

Absolute vs Relative Paths

A path is absolute if it starts from the filesystem root: /Users/ria/store/orders.csv on macOS or Linux, C:\Users\ria\store\orders.csv on Windows. Anything else is relative, and the operating system resolves it against the current working directory.

os.path.isabs(path) returns True when the path is absolute and False otherwise. The check is purely textual; it doesn't touch the disk.

Relative paths are convenient (open("orders.csv") is shorter than the full path), but they tie the program's behavior to wherever it was started. A relative path that works when you run a script from the project root fails when a scheduler launches the same script from /. Library code should prefer absolute paths, or build them on demand from a base directory the caller supplies.

The pattern is "take a known base directory plus a relative path, produce a full absolute path". That's what most application path handling looks like internally.

The Current Working Directory

Every process has a current working directory. Relative paths in that process get resolved against it. Python exposes it through os.getcwd() and lets you change it with os.chdir(path).

Reading the working directory is cheap and usually fine. Changing it mid-program is risky because every relative path you touch after the call resolves against the new directory, and other code in the same process may have cached paths that assumed the old one. The rule of thumb: avoid chdir when possible. Build absolute paths instead.

os.chdir is process-global state. Two threads racing through chdir calls can leave the working directory in whatever order the OS scheduler picks. For multi-threaded code, absolute paths are the only safe option.

Joining Paths: os.path.join

os.path.join(a, b, c, ...) concatenates path components with the platform separator, handling the edge cases that hand-rolled string concatenation gets wrong.

Three behaviors stand out. First, the join handles a trailing slash on the left component without doubling it. Second, the join inserts the separator when one isn't present. Third: if a component starts with a separator, it's treated as a new absolute path and everything before it is discarded. The last line shows this: /exports is absolute, so os.path.join("store", "/exports") returns /exports and drops store entirely.

That third behavior is the most common bug with os.path.join. It causes trouble when one of the components comes from user input or a config file that includes a leading slash:

If user_subpath is treated as a "subpath inside /safe/exports", the result is nothing of the sort. For paths built from untrusted input, validate the components first or use pathlib.Path (which gives the same result but at least makes the rule explicit by the slash operator's behavior).

Splitting Paths: split, splitext, basename, dirname

The inverse operations break a path into pieces. Four functions cover almost every use:

FunctionReturnsExample
os.path.split(p)(head, tail) where tail is the last component('/a/b', 'c.csv')
os.path.splitext(p)(root, ext) where ext includes the dot('/a/b/c', '.csv')
os.path.basename(p)Just the last component'c.csv'
os.path.dirname(p)Everything before the last component'/a/b'

split and splitext are the foundational ones; basename and dirname are shortcuts (basename(p) == split(p)[1], dirname(p) == split(p)[0]).

splitext is useful for grouping files by type. The extension includes the leading dot, and the "root" is everything up to (and not including) that dot:

For archive.tar.gz, only the final .gz is treated as the extension; the leading .tar stays in the root. Files without an extension produce an empty ext string. Hidden files on Unix (.bashrc) are handled the same way: splitext(".bashrc") returns ('.bashrc', '') because a leading dot doesn't count as an extension separator.

A common pattern is changing a file's extension while keeping its directory and name intact:

Checking What's on Disk

os.path has a small family of predicates that ask the OS whether a path corresponds to something real. Each one makes a stat system call internally and returns False (not an error) when the path doesn't exist.

FunctionReturns True when
os.path.exists(p)p is anything that exists (file, dir, symlink, FIFO)
os.path.isfile(p)p is a regular file
os.path.isdir(p)p is a directory
os.path.islink(p)p is a symbolic link

isfile and isdir follow symbolic links, so a symlink pointing to a file makes isfile return True. Use islink to check the link itself.

The "look before you leap" pattern (check, then open) is convenient but races against other processes that might delete or replace the file between the check and the open. For most cases it's harmless; for security-sensitive code, prefer "try the operation and handle the exception" so the check and the use happen atomically.

Both forms work for ordinary cases. The first is the Python convention because it's the only one that's correct in the presence of concurrent file activity.

Each predicate is one stat system call. For a single check, that's microseconds. For multiple answers about the same path (size, mtime, is-file), call os.stat(path) once and read several attributes from the result; chaining exists then getsize then getmtime makes three calls when one will do.

Anchor Paths: Home, Current Directory, Temp

A few well-known directories show up in almost every program. Python gives you portable ways to find them.

Home Directory

os.path.expanduser(p) expands a leading ~ (or ~user) into the user's home directory. If the path doesn't start with ~, it's returned unchanged.

The form ~ria (other user's home) also works on Unix. On Windows the function looks up the home directory from the environment (USERPROFILE or the combination of HOMEDRIVE and HOMEPATH).

Calling expanduser on every user-supplied path is safe: it's a no-op when there's no tilde, and it gives users the shorthand they expect from shells.

Current Directory

os.getcwd() returns the current working directory as an absolute path. Use it sparingly, because it depends on where the process was started, but it's useful for log messages and for resolving relative paths explicitly:

The current directory is also represented as . in path strings, and the parent directory as ... Both work inside os.path.join:

These show up most often in command-line tools that follow shell conventions.

Temporary Directory

For temporary files (intermediates, downloads, in-progress exports), use the platform's official temp directory. The standard way to get a path inside it is through the tempfile module. For quick lookups, two functions are useful:

The temp directory differs per OS (/tmp on Linux, %TEMP% on Windows, a per-user folder on macOS). Reading the value through tempfile.gettempdir() is portable; hard-coding /tmp is not.

tempfile.gettempdir() caches the result the first time it's called, so repeated lookups are essentially free. There's no need to cache it in your own code.

Resolving and Normalizing Paths

User-supplied or programmatically-built paths often need cleanup before they're useful. Several functions take a path and produce a more normalized version of it.

os.path.abspath

os.path.abspath(p) makes the path absolute by prepending the current working directory if it's relative. It also collapses .. and . segments. It does not check whether the path exists.

The .. segment is collapsed even when the path component it would skip doesn't exist on disk. That's a textual normalization, not a filesystem walk.

os.path.realpath

os.path.realpath(p) does everything abspath does and additionally resolves any symbolic links along the path, returning the canonical location of the underlying file.

abspath keeps the symlink in place; realpath follows it to the real file. Use realpath for comparing paths for equality (because two textually different paths can resolve to the same file on disk) or to anchor a path to its true location.

os.path.normpath

os.path.normpath(p) collapses redundant separators and ./.. segments without making the path absolute. It also converts forward slashes to backslashes on Windows.

Use normpath for tidying up display strings or comparing paths textually. For "is this path absolute and unambiguous on disk?" use abspath or realpath instead.

Comparing Paths

Comparing two paths with == works only if they're textually identical. Two paths that point to the same file but differ in formatting (trailing slash, redundant ./, mixed-case drive letter on Windows) will not compare equal.

For "do these paths look the same?" use normpath. For "do these paths point to the same file?" use realpath (or os.path.samefile(a, b), which does the same comparison without you doing the resolving yourself):

samefile raises FileNotFoundError if either path doesn't exist. That's a deliberate check: it's hard to ask "do these point to the same thing?" about a thing that isn't there.

Cross-Platform Considerations

The platform's separator is exposed as os.sep:

On Windows, os.sep is \\ (a backslash) and os.altsep is /. Most Windows APIs accept either, which is why hand-rolled "a/b" paths often work even on Windows. "Often" is not "always", and code that ships everywhere should let os.path.join (or pathlib) pick the separator.

Other Windows-specific traps:

  • Drive letters. A Windows path can include a drive (C:\Users\ria). os.path.splitdrive separates the drive from the rest; on Unix it returns ('', path).
  • Case sensitivity. Unix filesystems are case-sensitive (Orders.csv and orders.csv are different files). Windows and macOS default filesystems are case-insensitive but case-preserving. Don't write code that relies on file names differing only in case to be distinct.
  • Reserved names. Windows reserves names like CON, PRN, AUX, NUL, COM1 through COM9, and LPT1 through LPT9. Creating files with those names fails, regardless of extension.
  • Path length. Classic Windows APIs cap path length at 260 characters. Modern Windows supports longer paths but only with a specific opt-in. When building deep directory trees on Windows, keep an eye on the total length.

For most code, os.path.join and pathlib handle these details. Be aware they exist, and avoid hard-coding platform-specific assumptions.

Path Pitfalls and How to Avoid Them

A summary of the mistakes this lesson covered, with the fix for each:

MistakeSymptomFix
String concatenation: base + "/" + nameBreaks on Windows; wrong with trailing slashesUse os.path.join(base, name) or Path(base) / name
Joining absolute component: os.path.join("safe", "/etc/passwd")Earlier components discardedValidate components; or use pathlib.PurePath rules
Hard-coded /tmp or \UsersBreaks on other OS or in containersUse tempfile.gettempdir() and os.path.expanduser("~")
Comparing paths with ==Misses equivalent paths in different formsUse os.path.normpath or os.path.samefile
os.path.exists then openRace condition between check and useUse try/except FileNotFoundError
Calling os.chdir in shared codeGlobally changes path resolutionBuild absolute paths instead
Trusting splitext for double extensionsTreats .tar.gz as just .gzStrip extensions explicitly if you need both

The single biggest improvement most codebases can make is going from string concatenation to os.path.join (or, better, pathlib.Path). Most other path bugs are downstream of doing that one thing wrong.

A Brief Preview of pathlib

pathlib.Path is the modern alternative to os.path strings. Here are the same operations side by side as a preview.

The pathlib versions are usually shorter, type-safe, and easier to read. The os.path versions remain useful for a few reasons: they appear in older codebases, they take string arguments that some libraries still require, and they're the right level of abstraction when paths are data (logs, config keys, manifest entries) rather than something to open.

For new code, prefer pathlib unless something specifically needs strings. For existing code, leave os.path in place and convert when the surrounding code becomes awkward.

Quiz

Working with Paths Quiz

10 quizzes