# Joins with the right separator for the current OS
file.path("data", "raw", "survey.csv")[1] "data/raw/survey.csv"
List, find, create, copy, move, and delete files the portable way — with base R and the modern fs package, plus here() for project-relative paths
A practical reference for working with files and directories in R: build and manipulate paths, list and find files, check existence and file info, and create, copy, move, rename, and delete files and folders — shown in both base R (file.exists(), dir.create(), list.files(), file.path(), …) and the modern fs package. The habit that matters most is portable paths: never setwd(), never a hard-coded absolute path — use here::here() and file.path() so your script runs on someone else’s machine too.
setwd() or hard-code an absolute path, and what to do instead (here::here() + file.path()).list.files() / list.dirs().file.exists(), dir.exists(), file.size(), file.info().Reading and writing data in R almost always means touching the file system: finding the CSV you saved yesterday, creating an output folder, cleaning up a directory of results. R has everything you need built in — plus a modern package, fs, that smooths over base R’s rough edges.
The single most important idea is not a function, though — it’s a habit. A path can be absolute (/Users/you/project/data.csv — spelled out from the file-system root) or relative (data/raw/survey.csv — interpreted from your current working directory, the folder R treats as “here”). A project-relative path is a relative path anchored to your project’s root folder rather than to wherever R happens to be sitting. Getting that anchoring right is the difference between a script that runs everywhere and one that runs only on the machine that wrote it. We’ll start there.
The tempting shortcut is to set the working directory at the top of a script:
This works exactly once — on your machine, in that folder, today. The moment a collaborator opens the script, or you move the project, or it runs in CI, that absolute path is wrong. As the tidyverse’s project-oriented workflow guide puts it bluntly: “The chance of the setwd() command having the desired effect — making the file paths work — for anyone besides its author is 0%.”
Two tools fix this. First, file.path() joins path pieces with the correct separator for whatever operating system you’re on (/ on macOS/Linux, \ on Windows) — so you never hard-code a separator:
[1] "data/raw/survey.csv"
Second, the here package resolves paths from your project root — the folder containing your .Rproj or .git. here::here() returns the same logical location on every machine, because it finds the root at run time instead of trusting the current directory. The here package documentation states it plainly: “The here package creates paths relative to the top-level directory.”
Use here::here() for paths into your project, and file.path() (or fs::path(), below) for assembling paths generally. Neither depends on setwd(), so your script survives being moved, shared, or run by a machine.
When you paste in a path-handling snippet — from an AI assistant, from StackOverflow, or from an old tutorial — the tell-tale anti-pattern is a bare setwd("/Users/…") or a hard-coded absolute path. It runs on the author’s machine and nowhere else. Before you trust it, ask one question: would this resolve on a collaborator’s laptop, or in CI? If it calls setwd() or hard-codes an absolute path, swap in here::here() and file.path(). That check — matching the snippet against the reproducibility assumption, not just against “does it run for me right now” — is the judgment a generated answer won’t make for you.
For the runnable examples below, we’ll work inside a throwaway scratch folder in R’s per-session temporary directory (tempdir()), so nothing here touches your real files:
# A disposable scratch folder — unique to this R session, auto-cleaned on exit.
scratch <- file.path(tempdir(), "files-demo")
unlink(scratch, recursive = TRUE) # start from a clean slate
dir.create(scratch)
# Seed it with a few files to work with.
writeLines("a,b\n1,2", file.path(scratch, "data.csv"))
writeLines("notes", file.path(scratch, "README.md"))
writeLines("x", file.path(scratch, "script.R"))
list.files(scratch)[1] "data.csv" "README.md" "script.R"
list.files() returns just the file names (not the full temp path), which is exactly what we want to display — reproducible, and free of any machine-specific location.
list.files() (also spelled dir()) lists the entries in a folder. Give it a pattern — a regular expression — to filter, and recursive = TRUE to descend into subfolders. The base R documentation documents both arguments.
[1] "data.csv"
[1] "data.csv" "raw/extra.csv" "README.md" "script.R"
Recursive listing returns paths relative to the folder you asked about (raw/extra.csv), which keeps the output portable. To list only directories, use list.dirs():
[1] "" "raw"
(The empty string "" is the top folder itself.) The modern equivalent is fs::dir_ls(), which returns a tidy character vector and takes a glob (like *.csv) or regexp filter:
Before you read or overwrite anything, check it’s there. file.exists() and dir.exists() return simple logicals — perfect for an if () guard. See the base R file.exists and dir.exists help pages.
[1] TRUE
[1] TRUE
[1] FALSE
For size and metadata, file.size() returns bytes, and file.info() returns a full metadata data frame (size, modification time, permissions, and whether it’s a directory):
The fs versions are fs::file_exists(), fs::dir_exists(), and fs::file_info() — same idea, vectorised and consistently named.
dir.create() makes a folder; pass recursive = TRUE to create any missing parent folders in one call (without it, a missing parent is an error). file.create() makes an empty file.
[1] TRUE
[1] TRUE
[1] TRUE
The fs equivalent, fs::dir_create(), is recursive by default (no flag needed) and doesn’t warn if the folder already exists — one of the small consistency wins that make fs pleasant:
file.copy() duplicates a file; file.rename() both renames and moves (there’s no separate “move” in base R — renaming to a different folder moves it). Both return TRUE on success.
[1] TRUE
[1] TRUE
[1] "analysis.R" "data-backup.csv" "data.csv"
fs splits these into clearly-named verbs — fs::file_copy(), fs::file_move() — so the intent reads off the code:
Deletion is the one operation with no undo, so treat it with care. file.remove() deletes files. unlink() deletes files or folders — but for a folder you must pass recursive = TRUE, or nothing happens. The unlink() documentation is explicit: “If recursive = FALSE directories are not deleted, not even empty ones.”
[1] TRUE
[1] FALSE
[1] FALSE
Because there’s no recycle bin here, check before you delete — guard the call with file.exists() / dir.exists(), and never point unlink(recursive = TRUE) at a path you built by string-pasting without inspecting it first. The fs verbs, fs::file_delete() and fs::dir_delete(), are self-documenting about which one you meant.
Base R’s file functions grew over decades, so their names are inconsistent (file.exists() but dir.create(); unlink() for “delete”). fs gives you one consistent, cross-platform, vectorised interface — the fs documentation describes it as “a cross-platform, uniform interface to file system operations.” Every fs function takes and returns tidy fs_path character vectors, uses / on every OS, and names things predictably (dir_* for directories, file_* for files, path_* for path strings). Here’s the translation:
| Task | Base R | fs |
|---|---|---|
| Build a path | file.path("a", "b") |
fs::path("a", "b") |
| Project-relative path | here::here("a", "b") |
here::here("a", "b") |
| List files | list.files(dir, pattern=) |
fs::dir_ls(dir, glob=) |
| List directories | list.dirs(dir) |
fs::dir_ls(dir, type="directory") |
| File exists? | file.exists(p) |
fs::file_exists(p) |
| Directory exists? | dir.exists(p) |
fs::dir_exists(p) |
| File size / info | file.size(p) / file.info(p) |
fs::file_info(p) |
| Create directory | dir.create(p, recursive=TRUE) |
fs::dir_create(p) |
| Create empty file | file.create(p) |
fs::file_create(p) |
| Copy a file | file.copy(from, to) |
fs::file_copy(from, to) |
| Move / rename | file.rename(from, to) |
fs::file_move(from, to) |
| Delete a file | file.remove(p) / unlink(p) |
fs::file_delete(p) |
| Delete a directory | unlink(p, recursive=TRUE) |
fs::dir_delete(p) |
| File extension | tools::file_ext(p) |
fs::path_ext(p) |
Base R is always available with zero dependencies — reach for it in packages and scripts you want dependency-light. Reach for fs when you want the consistency, the vectorised behaviour, and paths that look the same on every operating system.
setwd() works for you but breaks for everyone else. An absolute path or a setwd() call bakes your machine’s folder layout into the script. Delete it. Anchor paths to the project root with here::here() and build them with file.path() (or fs::path()), so the code is identical on every machine even though the resolved path differs.
Backslash paths fail on other operating systems. Hard-coding "data\\raw\\file.csv" (Windows) breaks on macOS/Linux, and vice-versa. Never type the separator yourself — let file.path() or fs::path() insert the right one for the current OS.
unlink() “didn’t delete” my folder. By default unlink() refuses to remove directories — you must pass recursive = TRUE. (file.remove() only ever removes files, so it silently fails on a folder too.) For a directory, use unlink(path, recursive = TRUE) or the clearer fs::dir_delete().
Use list.files("path/to/folder"), which returns the file names in that folder. Add pattern = "\\.csv$" to filter with a regular expression, recursive = TRUE to descend into subfolders, and full.names = TRUE if you want complete paths rather than bare names. The modern equivalent is fs::dir_ls("path/to/folder"), which accepts a glob = "*.csv" filter and returns a tidy path vector.
Use dir.create("path/to/folder"). To create any missing parent folders in the same call, add recursive = TRUE — otherwise a missing parent is an error. fs::dir_create("path/to/folder") does the same thing, is recursive by default, and doesn’t warn if the folder already exists.
Use file.exists("path/to/file"), which returns TRUE or FALSE — ideal for guarding a read or write with an if (). For a folder, use dir.exists("path/to/folder"). The fs versions are fs::file_exists() and fs::dir_exists().
No — avoid it. setwd() hard-codes your machine’s folder layout into the script, so it works only on your computer, in that folder, right now; it breaks for collaborators, after a move, and in CI. Instead, keep your work in a project (an RStudio .Rproj or a Git repo) and build paths with here::here(), which resolves them relative to the project root on any machine.
They do the same jobs, but fs is more consistent. Base R’s functions grew over time and are unevenly named (file.exists() vs dir.create(); unlink() for “delete”), return different value types, and can behave differently across operating systems. The fs package gives one uniform, vectorised, cross-platform interface — predictable dir_* / file_* / path_* names, / separators everywhere, and tidy path vectors. Base R needs no dependencies; fs needs the package but is nicer for interactive and pipeline code.
You want a snippet that empties and recreates an output/ folder at your project root, ready for a fresh run — deleting it if it’s there, then making it again. Write it so it runs unchanged on a collaborator’s machine (no setwd(), no absolute path).
You need three pieces: a project-relative path (not an absolute one), a recursive delete that works on a directory, and a create call. Which function refuses to remove a folder unless you tell it to recurse?
here("output") anchors the path to the project root, so the code is identical on every machine.unlink(..., recursive = TRUE) is required to delete a directory — plain unlink() (or file.remove()) won’t remove a folder. unlink() on a path that doesn’t exist is harmless, so this is safe even on the first run.dir.create(out) remakes the now-empty folder.The fs one-liner is fs::dir_create(here("output")) after fs::dir_delete() — or just re-create, since dir_create() is happy if it already exists.
Quick check. You call unlink(file.path(scratch, "results")) (without recursive = TRUE) on a folder. What happens?
Nothing — the folder is left in place. unlink() refuses to delete directories unless you pass recursive = TRUE; without it, “not even empty ones” are removed. Add recursive = TRUE (or use fs::dir_delete()) to actually remove a directory tree.
Working with files in R is two things: the functions, and the habit. The functions are small and learnable — list.files(), file.exists(), dir.create(recursive = TRUE), file.copy(), file.rename(), unlink(recursive = TRUE) — with the modern fs package offering a consistent, cross-platform version of each. The habit is what makes your code reproducible: never setwd(), never a hard-coded absolute path — anchor everything with here::here() and assemble it with file.path() (or fs::path()). Do that, and a script you wrote today still runs on a collaborator’s laptop, in CI, and on your own machine a year from now.
here() beats setwd(). · Installing R Packages — how to get fs and here (and everything else). · R Programming Foundations — the full series.@online{kassambara2026,
author = {Kassambara, Alboukadel},
title = {Files and {Directories} in {R:} {A} {Reproducible} {Guide}},
date = {2026-07-18},
url = {https://www.datanovia.com/blog/files-and-directories-in-r},
langid = {en}
}