Paths with fs

Adam Shen

July 2, 2026

The fs package

The fs package provides consistent, clean (tidy) tools for working with file/folder paths.

Why fs?

  1. Provides tools for easy path construction (like what we've seen with the here package). No more using string concatenating/interpolating functions, such as:
    • paste() / paste0()
    • str_c() / str_flatten()
    • glue()
  2. As opposed to the varying function naming conventions found in base-R, fs uses consistent function naming conventions, making functions easier to remember:
    • path_*() for manipulating and constructing paths.
    • file_*() for actions concerning files and file paths.
    • dir_*() for actions concerning directories and directory paths.

Getting started

library(fs)

Using string functions for paths requires thinking

  • With functions such as paste(), paste0(), str_c(), str_flatten(), and glue(), you still need to take care of the slash separators.

  • It's not the most difficult thing in the world, and you can use the sep parameter, but it does require a bit of thinking and can get messy when mixing constant strings with string variables, or when accepting user input.

Using string functions for paths requires thinking

Examples:

base_dir <- "./dir1/dir2"
animals <- c("cat", "dog", "bird")

paste(base_dir, animals, "file.abc", sep = "/")
[1] "./dir1/dir2/cat/file.abc"  "./dir1/dir2/dog/file.abc"  "./dir1/dir2/bird/file.abc"
paste0(base_dir, "/", animals, "/file.abc")
[1] "./dir1/dir2/cat/file.abc"  "./dir1/dir2/dog/file.abc"  "./dir1/dir2/bird/file.abc"
Sys.getenv("R_USER")
[1] "C:\\Users\\Adam\\Documents"
stringr::str_c(Sys.getenv("R_USER"), animals, "file.abc", sep = "/")
[1] "C:\\Users\\Adam\\Documents/cat/file.abc"  "C:\\Users\\Adam\\Documents/dog/file.abc" 
[3] "C:\\Users\\Adam\\Documents/bird/file.abc"

Creating paths with fs

  • We can create paths with fs using path():

    path(Sys.getenv("R_USER"), animals, "file.abc")
    C:/Users/Adam/Documents/cat/file.abc  C:/Users/Adam/Documents/dog/file.abc  C:/Users/Adam/Documents/bird/file.abc 
  • We can optionally use the ext parameter to set an extension, but this may not be ideal for use when accepting user input for a file name:

    path(base_dir, animals, "file", ext = "abc")
    ./dir1/dir2/cat/file.abc  ./dir1/dir2/dog/file.abc  ./dir1/dir2/bird/file.abc 
    path(base_dir, animals, "file.abc", ext = "abc")
    ./dir1/dir2/cat/file.abc.abc  ./dir1/dir2/dog/file.abc.abc  ./dir1/dir2/bird/file.abc.abc 

Creating paths with fs

Things that are good to know:

  • When creating paths with fs, directory paths never end in trailing slashes:

    path("dir1/dir2/")
    dir1/dir2
  • Repeated slashes are reduced to single slashes:

    path("dir1//dir2///file.abc")
    dir1/dir2/file.abc
  • Double backslashes are normalised to forward slashes:

    Sys.getenv("HOME")
    [1] "C:\\Users\\Adam\\Documents"
    path(Sys.getenv("HOME"))
    C:/Users/Adam/Documents
    

Extracting parts of a path

  • Condsider the following file path (coercing to a fs_path is optional):

    this_file <- path(here::here("qmd", "2026-05-12.qmd"))
    this_file
    C:/Users/Adam/My Drive/GitHub/fast-talks/qmd/2026-05-12.qmd
  • We can obtain the name of the file using path_file():

    path_file(this_file)
    [1] "2026-05-12.qmd"
  • We can obtain the directory containing the file using path_dir():

    path_dir(this_file)
    [1] "C:/Users/Adam/My Drive/GitHub/fast-talks/qmd"

Extracting parts of a path

  • We can also get all the components of the file path, as a list, using path_split():

    path_split(this_file)
    [[1]]
    [1] "C:"             "Users"          "Adam"           "My Drive"       "GitHub"         "fast-talks"    
    [7] "qmd"            "2026-05-12.qmd"

Checking existence

  • We can check if a file exists using file_exists():

    file_exists(this_file)
    C:/Users/Adam/My Drive/GitHub/fast-talks/qmd/2026-05-12.qmd 
                                                          FALSE 
  • We can check if a directory exists using dir_exists():

    path_dir(this_file)
    [1] "C:/Users/Adam/My Drive/GitHub/fast-talks/qmd"
    dir_exists(path_dir(this_file))
    C:/Users/Adam/My Drive/GitHub/fast-talks/qmd 
                                            TRUE 

Creating directories

  • Directories can be created using dir_create():

    dir_create("this/that/what")
  • By default, intermediate directories are also created, as opposed to dir.create() where recursive = FALSE by default.

  • dir_create() also checks existence of the specified directory. If it already exists, nothing happens. In some cases, it may be more convenient to call dir_create() rather than creating a directory conditionally by first checking for exists with dir_exists().

Listing items in a directory

  • In base-R, to list all files in a directory, we use list.files(). The fs equivalent is dir_ls(). Observe the following differences in behaviour:

    list.files("../data/excel-data")
    [1] "pizza1.xlsx" "pizza2.xlsx" "pizza3.xlsx"
    list.files("../data/excel-data", full.names = TRUE)
    [1] "../data/excel-data/pizza1.xlsx" "../data/excel-data/pizza2.xlsx" "../data/excel-data/pizza3.xlsx"
    dir_ls("../data/excel-data")
    ../data/excel-data/pizza1.xlsx ../data/excel-data/pizza2.xlsx ../data/excel-data/pizza3.xlsx 
  • Rather than listing only the file names by default in list.files(), dir_ls() always lists the file paths relative to the path argument, equivalent to setting full.names = TRUE in list.files().

Listing items in a directory

  • Similar to list.files(), we can also supply a regular expression to dir_ls() to filter files:

    dir_ls("../docs")
    ../docs/2025-12-04.html ../docs/2026-01-06.html ../docs/2026-02-17.html ../docs/2026-07-02.html ../docs/index.html      
    ../docs/index.Rmd       
    
    dir_ls("../docs", regexp = "\\.Rmd")
    ../docs/index.Rmd
    
  • Alternatively, we can supply a globbing pattern, which behaves similarly to a regular expression:

    dir_ls("../docs", glob = "*.Rmd")
    ../docs/index.Rmd
    

Applying functions to items in a directory

  • A common task is to read all files in a directory into a single data frame. This can be achieved with a combination of dir_ls(), purrr::map(), and purrr::list_rbind().

  • The benefit of using dir_ls() over list.files() is that fs_paths are essentially named character vectors.

  • This makes it easy to include a column of file directories if you also need to include the source of a data file, without needing to manually set the names of the vector of file names.

Applying functions to items in a directory

list.files("../data/excel-data", full.names = TRUE) |>
  rlang::set_names() |>
  purrr::map(readxl::read_xlsx) |>
  purrr::list_rbind(names_to = "file")
# A tibble: 6 × 3
  file                           flavour        quantity
  <chr>                          <chr>             <dbl>
1 ../data/excel-data/pizza1.xlsx cheese                3
2 ../data/excel-data/pizza1.xlsx pepperoni             4
3 ../data/excel-data/pizza2.xlsx veggie                4
4 ../data/excel-data/pizza2.xlsx hawaiian              5
5 ../data/excel-data/pizza3.xlsx deluxe                3
6 ../data/excel-data/pizza3.xlsx special deluxe        2
dir_ls("../data/excel-data") |>
  purrr::map(readxl::read_xlsx) |>
  purrr::list_rbind(names_to = "file")
# A tibble: 6 × 3
  file                           flavour        quantity
  <chr>                          <chr>             <dbl>
1 ../data/excel-data/pizza1.xlsx cheese                3
2 ../data/excel-data/pizza1.xlsx pepperoni             4
3 ../data/excel-data/pizza2.xlsx veggie                4
4 ../data/excel-data/pizza2.xlsx hawaiian              5
5 ../data/excel-data/pizza3.xlsx deluxe                3
6 ../data/excel-data/pizza3.xlsx special deluxe        2

Filtering paths

  • Sometimes we need to filter paths after receiving them (e.g. user input).

  • It would be a shame to leave the fs ecosystem and revert to working with raw strings... Thankfully, we don't need to!

  • We can filter paths using regular expressions with path_filter():

    dir_ls("../data/excel-data")
    ../data/excel-data/pizza1.xlsx ../data/excel-data/pizza2.xlsx ../data/excel-data/pizza3.xlsx 
    dir_ls("../data/excel-data") |>
      path_filter(regexp = "1")
    ../data/excel-data/pizza1.xlsx
    dir_ls("../data/excel-data") |>
      path_filter(regexp = "pizza[^2]")
    ../data/excel-data/pizza1.xlsx ../data/excel-data/pizza3.xlsx 

Getting a literal path

  • Sometimes we end up with paths with shorthand characters like ., .., and ~. For example, with system environment variables:

    Sys.getenv("R_HOME")
    [1] "C:/PROGRA~1/R/R-44~1.2"
  • To clean these up, in addition to things like backslashes (on Windows) and get the literal path, we can use path_real():

    path_real(Sys.getenv("R_HOME"))
    C:/Program Files/R/R-4.4.2
    
  • The equivalent in base-R is normalizePath(), but again, due to how inconsistently functions in base-R are named, this one can be difficult to remember:

    normalizePath(Sys.getenv("R_HOME"), winslash = "/")
    [1] "C:/Program Files/R/R-4.4.2"

Getting a literal path

path_real("../data/excel-data")
C:/Users/Adam/My Drive/GitHub/fast-talks/data/excel-data
Sys.getenv("TEMP")
[1] "C:\\Users\\Adam\\AppData\\Local\\Temp"
path_real(Sys.getenv("TEMP"))
C:/Users/Adam/AppData/Local/Temp

Moving/renaming files

  • Another convenient function is file_move() which allows you to move and/or rename files.

  • This function is especially useful when you want to rename a large number of files and it would be too time consuming or error-prone to rename the files manually. All you need to specify is the original path of the file and its new path.

  • I typically only use file_move() in an interactive setting... I think it's rare to need to use this non-interactively because it is typically a one-time thing.

Moving/renaming files

old_names <- dir_ls("../data/excel-data") |>
  path_filter(regexp = "pizza[^2]")
old_names
../data/excel-data/pizza1.xlsx ../data/excel-data/pizza3.xlsx 
new_names <- old_names |>
  stringr::str_replace(pattern = c("1", "3"), replacement = c("4", "6"))
new_names
  ../data/excel-data/pizza1.xlsx   ../data/excel-data/pizza3.xlsx 
"../data/excel-data/pizza4.xlsx" "../data/excel-data/pizza6.xlsx" 
# Optional, just to fix the names
new_names <- as_fs_path(new_names)
new_names
../data/excel-data/pizza4.xlsx ../data/excel-data/pizza6.xlsx 
file_move(old_names, new_names)

Accessing package files

  • To access supplementary files contained in a package (usually under the inst directory), one can use system.file().

  • However, this only works for packages that are installed on your computer.

  • To access files for a package that is under active development (and may yet to be installed on your device), we can use the path_package() function:

    # Load the package contents
    devtools::load_all()
    
    # Specify the path of the file relative to the package root
    path_package("your_pkgname", "inst/special_data.csv")

Ok bye

Now that you are equipped with here and fs, all of your path problems should vanish!