Scripts and the Command Line

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Checked against R 4.6.1 (2026-06-24) on 2026-10-20; R 4.6.2 is scheduled for 2026-10-29. The first part of this page is base R and a POSIX shell: Rscript, R, R CMD BATCH, commandArgs(), quit(), interactive(), the standard connections and the environment-variable functions all ship with R. Three add-on packages appear in the second part: optparse 1.8.2 and argparse 2.3.1 (which needs a Python 3 interpreter and the findpython 1.0.9, jsonlite 2.0.0 and R6 2.6.1 packages; the image used Python 3.12.3), and the shelf package 0.1.0 from Creating Packages, which the report script loads with library(shelf). The lintr 3.4.0 default linters were used to check the scripts. docopt and getopt are named in a sentence and not run. A script is the way an R analysis leaves the console: it can be run by make, by cron, by a continuous-integration job or by another program, and it can be given its input as arguments, files and pipes instead of typing. This page covers how R is started without a person at the keyboard, how a script finds its arguments and its standard input, how it reports success or failure to the process that started it, and how to parse a real set of options.

Every script is saved under the file name in its first comment line and was run with Rscript on R 4.6.1; the shell commands that run it are in a separate bash listing, and the lines that start with #> are what the command printed (trailing blanks removed). Because this page is about processes, the examples are a mix of R listings, which are complete files, and shell listings, which are shell sessions run in a directory that holds the scripts and the two CSV files of the running library-shelf scenario described on the section index, books.csv and loans.csv; a few shell lines create a small file first and are labelled as fragments. Where a transcript shows standard output and standard error together, it was captured through a pseudo-terminal, so the two streams interleave in the order a person would see them; when both are redirected into one pipe or file the order can differ, and the page says so where it matters. The exit status of a command is shown with echo $?. The scripts pass the default lintr linters except three that are not valid R on purpose (one with a syntax error, one with an unfinished statement and one whose data lines follow the code); those are marked. No timings, host names, absolute paths, user names or platform strings are printed: where R prints them, the page says what was shortened.

The page is the command-line companion of three others. Sessions, Workspaces and Startup describes the files R reads when it starts, which every script run reads too; Connections and the File System describes file(), stdin() and the connection objects behind the standard streams; and Conditions and Error Handling describes the conditions a script turns into exit statuses. The design of a command-line tool as such, whatever the language, is covered under Building CLIs for AI Agents.

Running R without a person at the console

R was designed for a person typing at a prompt, and most of its defaults assume one is there: it prints the value of every top-level expression, asks whether to save the workspace on exit, opens a window for a plot and offers to correct a mistyped answer. "Non-interactive" means the opposite: no one is available to answer, so R must never wait. The help page of interactive() lists what changes: errors are handled differently (an uncaught error ends the run), R never asks about the workspace, the default graphics device is a file instead of a window, and a device never asks for confirmation of a new page. A script is a text file of R code that is run in that mode.

"An Introduction to R" (Appendix B, "Invoking R") describes the ways to start R, and the table below puts them side by side. The manual still names R CMD BATCH first for running a file, and presents Rscript as the front end that makes passing arguments simpler; in practice Rscript is the one used for new work, for the reasons in the table.

Command What happens Commands echoed?

Rscript file.R a b

Runs R --no-echo --no-restore --file=file.R --args a b. No workspace is read or saved; start-up files are read. The usual choice.

No

Rscript -e 'expr'

The same, with one or more expressions instead of a file.

No

R CMD BATCH file.R [out]

Runs the file with --restore --save --no-readline unless told otherwise and writes commands and output to file.Rout (or out), with a timing trailer.

Yes, in the output file

R -f file.R or R --file=file.R

Runs the file in the ordinary R front end; prints the banner unless --quiet.

Yes, unless --no-echo

R < file.R

Reads the commands from standard input, as if typed; stdin() refers to that input.

Yes

R -e 'expr'

One expression, echoed like typed input.

Yes

source("file.R")

Inside a running session; no --args, no new process.

Only with echo = TRUE

Rscript

Rscript is a small executable that sits next to R. Its help page calls it an alternative front end for use in #! scripts and other scripting applications, and its job is to turn a convenient command line into the longer one that R needs.

Usage and options

Rscript --help prints the whole interface, which is short enough to read in full.

Rscript --help
#> Usage: Rscript [options] file [args]
#>    or: Rscript [options] -e expr [-e expr2 ...] [args]
#> A binary front-end to R, for use in scripting applications.
#>
#> Options:
#>   --help              Print usage and exit
#>   --version           Print version and exit
#>   --verbose           Print information on progress
#>   --default-packages=LIST  Attach these packages on startup;
#>                         a comma-separated LIST of package names, or 'NULL'
#> and options to R (in addition to --no-echo --no-restore), for example:
#>   --save              Do save workspace at the end of the session
#>   --no-environ        Don't read the site and user environment files
#>   --no-site-file      Don't read the site-wide Rprofile
#>   --no-init-file      Don't read the user R profile
#>   --restore           Do restore previously saved objects at startup
#>   --vanilla           Combine --no-save, --no-restore, --no-site-file,
#>                         --no-init-file and --no-environ
#>
#> Expressions (one or more '-e <expr>') may be used *instead* of 'file'.
#> Any additional 'args' can be accessed from R via 'commandArgs(TRUE)'.
#> See also  ?Rscript  from within R.

Rscript --version prints the version banner and exits.

Rscript --version
#> Rscript (R) version 4.6.1 (2026-06-24)

What Rscript runs

The help page says that Rscript always supplies --no-echo --no-restore (which imply --no-save), and --verbose shows the command it builds. The transcript runs a script that prints its own commandArgs(); the long path of the R launcher in the first line is shortened to R. The transcripts on this page come from an R built with the default configuration; a build such as the rocker Docker images adds two more elements at the front.

Rscript --verbose args.R a 2>&1 | sed "s|'[^ ]*R --no-echo|'R --no-echo|"
#> running
#>   'R --no-echo --no-restore --file=args.R --args a'
#>
#> number of elements: 6
#> [1] "--no-echo"     "--no-restore"  "--file=args.R" "--args"
#> [5] "a"
#> [1] "a"

The script behind the lines after the blank one is args.R. It prints how many elements commandArgs() returns, the vector without its first element, and the vector with trailingOnly = TRUE; the first element is the path of R’s executable, which is platform dependent, so it is dropped before printing.

# args.R: show what the script receives.
all_args <- commandArgs()
cat("number of elements:", length(all_args), "\n")
print(all_args[-1L])
print(commandArgs(trailingOnly = TRUE))

So Rscript args.R a is R --no-echo --no-restore --file=args.R --args a, and the options it adds mean:

Option Meaning

--no-echo

Do not echo the commands. --slave is the older name (the R 4.0.0 NEWS calls --no-echo "renamed from --slave"), -s is the short form, and --slave is still accepted.

--no-restore

Do not restore a saved .RData workspace or the command history. Together with the --no-save that --no-echo implies, nothing is read from or written to a workspace file.

--file=file.R

Take the commands from the file.

--args

Everything after this marker is left alone by R and is what commandArgs(trailingOnly = TRUE) returns.

Some builds put more in front of these. R’s start-up script, the R launcher, runs the real executable as R_binary, then the value that the R_BATCHSAVE variable had when R was configured, then the options it was given. R_BATCHSAVE is empty by default (the config.site file of the R sources has it commented out), but a build can set it, and the rocker Docker images for R (Rocker Project) are built with R_BATCHSAVE="--no-save --no-restore". On such a build the vector starts with --no-save and --no-restore occurs twice, so every count on this page is two higher:

Rscript args.R a
#> number of elements: 8
#> [1] "--no-save"     "--no-restore"  "--no-echo"     "--no-restore"
#> [5] "--file=args.R" "--args"        "a"
#> [1] "a"

Nothing is lost by the repetition, and a script that reads only commandArgs(trailingOnly = TRUE) is not affected; a script that looks for its own options by position in the full vector is. What Rscript does not add is --no-site-file, --no-init-file or --no-environ: the site and user environment files (Renviron, .Renviron) and profile files (Rprofile.site, .Rprofile) are read as in any R start, which is useful (a library path in .Renviron applies) and also a source of "works on my machine". --vanilla combines --no-save, --no-restore, --no-site-file, --no-init-file and --no-environ; it goes before the file name, and commandArgs() then lists it among R’s own options.

Rscript --vanilla args.R a
#> number of elements: 7
#> [1] "--no-echo"     "--no-restore"  "--vanilla"     "--file=args.R"
#> [5] "--args"        "a"
#> [1] "a"

A reproducible job, such as one run by a scheduler on a machine with other users, normally starts with Rscript --vanilla so that a stray .Rprofile cannot change its behaviour; the price is that settings it needed from .Renviron (a package library, a proxy) have to be provided another way. The files themselves are described in Sessions, Workspaces and Startup.

Expressions with -e, and the order of arguments

-e supplies the program on the command line, and it can be repeated; the expressions run in order, each at top level, so a value is autoprinted. Anything after the last expression is an argument for the program.

Rscript -e 'cat("one\n")' -e '2 + 2' extra1 extra2
#> one
#> [1] 4

The order is strict, and the help page says so: "the prescribed order of arguments is important", because an option placed after -e or after the file name is not read by Rscript or by R at all but passed on as an argument to the program. That is a feature: an option for your script can never be mistaken for one of R’s (the script’s own --help and --version are safe), and a mistake of the opposite kind, writing Rscript script.R --vanilla, silently does not do what it says. The next transcript passes R’s own option names to a script and shows them arriving as plain arguments.

Rscript args.R --version --help -e 1
#> number of elements: 9
#> [1] "--no-echo"     "--no-restore"  "--file=args.R" "--args"
#> [5] "--version"     "--help"        "-e"            "1"
#> [1] "--version" "--help"    "-e"        "1"

The program given with -e is subject to the quoting of the shell that starts it, and the help page warns about exactly that. In the Bourne-style shells the expression goes in single quotes and strings inside it in double quotes, as above. In cmd.exe the outer quotes must be double quotes and the inner strings single-quoted or escaped, and PowerShell has its own rules for quotes passed to native programs; a script file avoids the whole subject. When -e is used, commandArgs() has no --file= element, and commandArgs(TRUE) returns the arguments that follow the expressions.

Rscript -e 'commandArgs(TRUE)' a b c
#> [1] "a" "b" "c"
Rscript -e 'commandArgs()[-1]'
#> [1] "--no-echo"         "--no-restore"      "-e"
#> [4] "commandArgs()[-1]"

An empty expression is refused, and an uncaught error in one expression ends the run before the next, with status 1 like any other uncaught error (see Exit status).

Rscript -e 'cat("one\n")' -e 'stop("second expression")' -e '3'; echo $?
#> one
#> Error: second expression
#> Execution halted
#> 1
Rscript -e ''; echo $?
#> ERROR: option '-e' requires a non-empty argument
#> 1

The help page defines - as the file name for standard input: the script is then read from the pipe instead of a file. That is handy for generating a program in a shell pipeline, and unsuitable for a script that also needs to read data from standard input, which is already used up by the program.

echo 'cat(1 + 1, "\n")' | Rscript -
#> 2

Default packages

A fresh R session attaches datasets, utils, grDevices, graphics, stats and methods. Rscript does the same since R 3.5.0; before that it left methods out, so a script that used S4 classes or Reference Classes had to attach it itself, and the 3.5.0 NEWS added an environment variable to restore the old behaviour for a while. Two options control the set. --default-packages=LIST takes a comma-separated list of package names, or NULL for none, and sets the environment variable R_DEFAULT_PACKAGES for the new process. Without that option, Rscript checks R_SCRIPT_DEFAULT_PACKAGES first and falls back to R_DEFAULT_PACKAGES; the former takes precedence over the latter, and either can be used in a .Renviron or a Makefile. base is always attached. The script below reports what is on the search path.

# packages.R: which packages are attached at startup.
attached <- grep("^package:", search(), value = TRUE)
cat("attached:", sub("^package:", "", attached), "\n")
cat("methods attached:", "package:methods" %in% attached, "\n")
Rscript packages.R
#> attached: stats graphics grDevices utils datasets methods base
#> methods attached: TRUE
Rscript --default-packages=NULL packages.R
#> attached: base
#> methods attached: FALSE
Rscript --default-packages=utils,stats packages.R
#> attached: stats utils base
#> methods attached: FALSE
R_SCRIPT_DEFAULT_PACKAGES=utils R_DEFAULT_PACKAGES=NULL Rscript packages.R
#> attached: utils base
#> methods attached: FALSE

The last command shows the precedence: both variables are set, and only utils (plus base) is attached. The packages attach in the order given, so the search path lists them in reverse, as the transcript for utils,stats shows. A script that names its dependencies with library() or pkg::fn() works with any of these settings and starts faster with --default-packages=NULL; one that silently relied on stats or methods being attached will fail with "could not find function", which is a reason to write stats::median() in a script that is run with a reduced set. Setting R_DEFAULT_PACKAGES="" asks for R’s system default, which is the same as not setting it.

When Rscript cannot start the script

A missing script file is a fatal error of the front end and gives status 2, not R’s usual 1; an unknown option before the file produces a warning and the run goes on. In a shell where Rscript is not on the PATH, the shell, not R, prints "command not found" (bash’s wording), which is why a cron job or a service manager, with its minimal PATH, needs the full path to Rscript or a PATH assignment.

Rscript nope.R; echo $?
#> Fatal error: cannot open file 'nope.R': No such file or directory
#> 2
Rscript --nonsense -e '1'; echo $?
#> WARNING: unknown option '--nonsense'
#>
#> [1] 1
#> 0

On Windows the program is Rscript.exe, in the bin directory of the R installation; give its full path or add that directory to the PATH, and note that the #! line of a script is not interpreted by cmd.exe or PowerShell. The transcripts on this page were produced on Linux; the places where Windows differs are said where they come up.

Executable scripts

On Unix-like systems the kernel runs a file that starts with #! by starting the named interpreter with the file as its argument. For R the interpreter is Rscript, and since R treats # as a comment the line is ignored when R reads the file. "An Introduction to R" gives two forms: the first line #! /path/to/Rscript, which hard-codes a path, and #! /usr/bin/env Rscript, which finds Rscript on the PATH and is the one to use because the location differs between systems (a Homebrew install, a distribution package, a container). This is shelf-hello.R, a complete script that is a fragment only in the sense that it is meant to be run as a program, not with Rscript:

#!/usr/bin/env Rscript
args <- commandArgs(trailingOnly = TRUE)
cat("Shelf report for:", args, "\n")

The two steps that need a POSIX shell (and do not exist in cmd.exe) are setting the executable bit and running the file by its path. Without the bit the shell answers "Permission denied" (status 126), and without a ./ the shell looks only in the PATH.

chmod +x shelf-hello.R
./shelf-hello.R books.csv loans.csv
echo $?
#> Shelf report for: books.csv loans.csv
#> 0

The #! mechanism passes everything after the interpreter name as a single argument on many systems, and the manual notes that, at least in Bourne and bash shells, the #! mechanism does not allow extra arguments such as #! /usr/bin/env Rscript --vanilla. The failure is the one the next fragment shows: env receives the string Rscript --vanilla and looks for a program of that name (the path of env in the first line of its message is shortened). GNU env -S splits the string, which makes a flag in the shebang possible; -S is not portable (it is an extension of GNU env that recent BSD systems also have; check it on the target), and since R 3.5.0 Rscript splits a single argument string given on the #! line, so a script that names Rscript by its path can carry several options, as in #! /path/to/Rscript --vanilla --default-packages=utils, at the price of the hard-coded path. The alternative that always works is to put the setting in the script itself, or in a small sh wrapper that runs Rscript --vanilla. The three fragments below create a two-line file with printf, make it executable and run it.

printf '#!/usr/bin/env Rscript --vanilla\ncat("hello\\n")\n' > hello-vanilla
chmod +x hello-vanilla
./hello-vanilla
echo $?
#> env: ‘Rscript --vanilla’: No such file or directory
#> env: use -[v]S to pass options in shebang lines
#> 127
printf '#!/usr/bin/env -S Rscript --vanilla\ncat("hello\\n")\n' > hello-env-s
chmod +x hello-env-s
./hello-env-s
echo $?
#> hello
#> 0

A file saved with Windows line endings fails in a similar way on Linux and macOS, because the interpreter name then ends with a carriage return, shown as \r in the message. The same file runs when Rscript is called on it directly, since R accepts CRLF line endings in a script. Keep scripts that carry a shebang with LF endings (*.R text eol=lf in .gitattributes).

printf '#!/usr/bin/env Rscript\r\ncat("hello\\n")\r\n' > hello-crlf
chmod +x hello-crlf
./hello-crlf
echo $?
Rscript hello-crlf
#> env: ‘Rscript\r’: No such file or directory
#> env: use -[v]S to pass options in shebang lines
#> 127
#> hello

On Windows, run Rscript.exe script.R explicitly, or associate the .R extension with it; Git Bash and Cygwin shells understand shebang lines, cmd.exe and PowerShell do not. A shebang script with no extension (shelf-hello instead of shelf-hello.R) is the Unix convention for a program; R does not care about the extension, but editors and linters do, which is why the examples here keep .R.

Command-line arguments

commandArgs()

commandArgs() returns a copy of the command line as the R process received it, "captured before the standard R command line processing takes place", in the words of its help page, so the values are unmodified. The first element is the name of the executable and "the exact form of this element is platform dependent". With trailingOnly = TRUE it returns only what follows --args, which is where Rscript puts the script’s arguments. With the script args.R shown earlier, a run with three kinds of argument (an option with a value, a quoted argument containing a space, and something that looks like another option) gives:

Rscript args.R --genre fiction "two words" -x 3
#> number of elements: 10
#> [1] "--no-echo"     "--no-restore"  "--file=args.R" "--args"
#> [5] "--genre"       "fiction"       "two words"     "-x"
#> [9] "3"
#> [1] "--genre"   "fiction"   "two words" "-x"        "3"

The last line is the vector a script normally uses. Three facts follow from the transcript.

  • The shell, not R, splits the line into arguments: "two words" arrives as one element because the shell kept it together. R does no splitting of its own, so a script that needs a list in one argument has to split it itself (strsplit(args[[1]], ",", fixed = TRUE)).

  • Every element is a character string. 3 is "3"; a number or a date must be converted by the script, and validated, because as.integer("three") is NA with a warning, not an error.

  • With no script arguments there is no --args marker at all and commandArgs(trailingOnly = TRUE) is character(0), so args[[1]] fails with subscript out of bounds; test length(args) first.

Rscript args.R
#> number of elements: 4
#> [1] "--no-echo"     "--no-restore"  "--file=args.R"
#> character(0)

Older scripts write commandArgs(TRUE), which is the same thing: trailingOnly is the first and only argument, and the positional form is easy to misread. The --args marker belongs to R: it "skips the rest of the command line" (R --help), which is why --version, --help, -e 1 and -f other.R pass through as the strings they are, and commandArgs(TRUE) returns everything after the first marker, so a second --args typed by the user is returned as an ordinary argument.

The name of the running script

A script sometimes needs to know where it lives, to read a file kept beside it. When R runs a file it receives a --file= argument holding the path as it was given on the command line, which is relative if the command used a relative path. script-dir.R finds the directory from it, and fails with a clear message when there is no such argument, which is the case under Rscript -e and when the code is loaded with source().

# script-dir.R: find the directory of the running script.
file_arg <- grep("^--file=", commandArgs(), value = TRUE)
if (length(file_arg) == 0L) {
  stop("no --file= argument: not run as a script file")
}
script <- sub("^--file=", "", file_arg[[1L]])
script_dir <- dirname(script)
cat("script as given:   ", script, "\n")
cat("directory as given:", script_dir, "\n")
cat("books.csv beside the script:",
    file.exists(file.path(script_dir, "books.csv")), "\n")
mkdir sub
cp books.csv script-dir.R sub
Rscript sub/script-dir.R
cd sub
Rscript script-dir.R
cd ..
Rscript -e 'source("sub/script-dir.R")'
#> script as given:    sub/script-dir.R
#> directory as given: sub
#> books.csv beside the script: TRUE
#> script as given:    script-dir.R
#> directory as given: .
#> books.csv beside the script: TRUE
#> Error in eval(ei, envir) : no --file= argument: not run as a script file
#> Calls: source -> withVisible -> eval -> eval
#> Execution halted

The result is a path relative to the working directory of the process, which a script may change later, so convert it with normalizePath() at the top of the script if the directory is needed after the working directory has changed. This is a convention more than a promise: the help page of commandArgs() documents the function, and R --help documents -f FILE, --file=FILE, but neither guarantees the form of the value, so keep the trick in one place. Community packages solve the larger problem of finding the root of a project: here::here() builds paths from the top of the project it finds by looking for marker files (an .Rproj file, a DESCRIPTION, a .git directory), and rprojroot is the lower-level package it is built on. Both are named here as community tools and not run.

Quoting, positional arguments and R’s own options

  • Arguments with spaces need the shell’s quoting; the transcript above shows "two words" surviving. For R CMD BATCH the arguments come as one quoted string after the options, "--args one two", which makes a single argument containing spaces awkward (see R CMD BATCH).

  • Option names that R itself understands (--vanilla, --no-save, -e) are R’s only before the script name. After it they are the script’s.

  • A leading dash is no special case for commandArgs(): a negative number such as -1 arrives as "-1". A parser decides what is an option (see Parsing arguments).

  • Encodings: the arguments are bytes in the current locale; Encoding() and enc2utf8() apply as for any string, and the flag --encoding=ENC of R sets the encoding assumed for standard input (not for arguments). See Encodings, Locales and Internationalization.

interactive()

interactive() is TRUE when R is being used by a person at a console and FALSE otherwise. Under Rscript it is always FALSE. R started in a terminal "assumes that it is interactive if stdin is connected to a (pseudo-)terminal and not if stdin is redirected to a file or pipe", and the command-line option --interactive overrides the assumption. The script is one line, and the transcript runs it three ways.

# interactive.R: what interactive() says for this run.
cat("interactive():", interactive(), "\n")
Rscript interactive.R
#> interactive(): FALSE
echo 'interactive()' | R --quiet --no-save
#> > interactive()
#> [1] FALSE
#> >
echo 'interactive()' | R --quiet --no-save --interactive
#> > interactive()
#> [1] TRUE
#> >

R’s own code uses the flag: help(), the debugger, install.packages() and readline() behave differently without a person. A script that must work both ways says so with a guard. if (interactive()) { ... } keeps a line such as View(x), browser() or a prompt out of a batch run; if (!interactive()) runs the main program only when no one is at the console, the way a library file might hold code for development. readline() never waits in a script: it prints its prompt and returns "", as its help page says ("as if the response was RETURN"), which is a silent wrong answer, not an error.

# readline.R: readline() prompts only in an interactive session.
answer <- readline("Continue? ")
cat("answer: [", answer, "]\n", sep = "")
cat("interactive():", interactive(), "\n")
echo yes | Rscript readline.R
#> Continue?
#> answer: []
#> interactive(): FALSE

Input that a script needs belongs in arguments, files or file("stdin") (see Reading standard input), and a script that wants to confirm a destructive action should check interactive() or isatty() and refuse to prompt without a terminal. Whether a stream is a terminal is a separate question from interactive(), and isatty() answers it for the output connections; a program that prints colours or progress bars checks it for stdout().

# isatty.R: is the output going to a terminal?
cat("stdout is a terminal:", isatty(stdout()), "\n")
cat("stderr is a terminal:", isatty(stderr()), "\n")
Rscript isatty.R
#> stdout is a terminal: TRUE
#> stderr is a terminal: TRUE
Rscript isatty.R | cat
#> stdout is a terminal: FALSE
#> stderr is a terminal: TRUE
Rscript isatty.R > isatty.txt
cat isatty.txt
#> stdout is a terminal: FALSE
#> stderr is a terminal: TRUE

The three pairs of lines belong to the three commands, in order: both streams are the terminal in the first run; with a pipe, standard output is no longer a terminal; with a redirection, cat shows what the script recorded, and standard error is still the terminal. The rules for programs that change their output when a stream is not a terminal are in TTY, Colour & Interactivity.

Exit status

A process that ends returns a small integer to the process that started it, by convention 0 for success and anything else for failure. Shells (&&, ||, if, set -e), make, cron wrappers, CI systems and agent harnesses all decide from that number, so a script that prints "ERROR" and exits with 0 has not reported an error. quit() (alias q()) ends an R session: quit(save = "default", status = 0, runLast = TRUE).

quit() and its arguments

save is one of "no", "yes", "ask" or "default". The default asks in interactive use and "may be overridden by command-line arguments (which must be supplied in non-interactive use)"; Rscript always supplies --no-echo --no-restore, and --no-echo implies --no-save, so it never saves. A script that quits explicitly passes save = "no" so that it is correct whichever front end runs it. status is the number returned to the operating system, "0:255 are normally valid" and reduced modulo 256 by many systems. runLast = TRUE runs the function .Last(), if it exists, immediately before normal termination, followed by .Last.sys(). The script below takes the status from its first argument, defines a .Last() and calls quit() before a line that is never reached.

# quit-status.R: leave with the status given as the first argument.
args <- commandArgs(trailingOnly = TRUE)
status <- if (length(args) >= 1L) as.integer(args[[1L]]) else 0L
.Last <- function() cat("goodbye from .Last()\n")
cat("about to quit with status", status, "\n")
quit(save = "no", status = status)
cat("this line is never reached\n")
Rscript quit-status.R 3
echo "exit status: $?"
#> about to quit with status 3
#> goodbye from .Last()
#> exit status: 3
Rscript quit-status.R 300
echo $?
#> about to quit with status 300
#> goodbye from .Last()
#> 44

Status 300 became 44, which is 300 modulo 256, and a negative status (-1) is seen by the shell as 255: pick values from 0 to 255. The help page also records which numbers R uses itself. The default error handler of a non-interactive session "effectively calls q("no", 1, FALSE)" and returns 1, which is why .Last() is not run after an uncaught error; status 2 "is used for R suicide, that is a catastrophic failure"; and "it is recommended that users choose statuses of 10 or more". Section Choosing statuses says what the shelf report does about that advice. The .Last() function is for the end user to control: a package must not define it, and the finalizers registered with reg.finalizer(onexit = TRUE) are the programmatic equivalent.

An uncaught error ends the run with status 1

Outside a tryCatch() an error stops the script: R prints the message, a Calls: line when there is a call stack, and Execution halted, and the process exits with status 1. Nothing after the error runs.

# error-status.R: an uncaught error ends the run.
cat("before the error\n")
total <- sum(c(1, 2, 3)) + "a"
cat("after the error\n")
Rscript error-status.R
echo "exit status: $?"
#> before the error
#> Error in sum(c(1, 2, 3)) + "a" : non-numeric argument to binary operator
#> Execution halted
#> exit status: 1

A warning does not change the status. The next script converts a string that is not a number; R prints a deferred warning after the top-level call that raised it and the script goes on, ending with status 0, so a pipeline cannot tell that the data were bad.

# warning-status.R: a warning does not change the exit status.
cat("before the warning\n")
x <- as.integer("three")
cat("after the warning:", x, "\n")
Rscript warning-status.R
echo "exit status: $?"
#> before the warning
#> Warning message:
#> NAs introduced by coercion
#> after the warning: NA
#> exit status: 0

options(warn = 2) turns every warning into an error, which makes a script strict: the first warning ends the run with status 1 and the message "(converted from warning)". It suits a script whose input should be clean and is too blunt for one that expects some warnings from a library, in which case the offending call is wrapped in withCallingHandlers() or tryCatch(warning = ...). The mechanics of both are in Conditions and Error Handling.

# warn-as-error.R: turn every warning into an error.
options(warn = 2)
cat("reading the year\n")
year <- as.integer("20x6")
cat("never reached:", year, "\n")
Rscript warn-as-error.R
echo "exit status: $?"
#> reading the year
#> Error: (converted from warning) NAs introduced by coercion
#> Execution halted
#> exit status: 1

options(error =) and catch.script.errors

The option error holds a function or an expression that R runs when an error reaches the top level, after the message is printed. In a non-interactive session the default is to halt with status 1. A handler can log, print a traceback() (see Debugging) and leave with the status of the script’s own choosing, with runLast = FALSE to keep the end-of-session .Last() from running on a failed state.

# error-option.R: run a handler when an error reaches the top level.
options(error = function() {
  message("handler: the run failed, leaving with status 3")
  quit(save = "no", status = 3L, runLast = FALSE)
})
check_year <- function(year) {
  stopifnot(is.numeric(year), year > 0)
  year
}
check_year(2026)
check_year("2026")
cat("never reached\n")
Rscript error-option.R
echo "exit status: $?"
#> [1] 2026
#> Error in check_year("2026") : is.numeric(year) is not TRUE
#> Calls: check_year -> stopifnot
#> handler: the run failed, leaving with status 3
#> exit status: 3

The first call, check_year(2026), printed [1] 2026 because its value is autoprinted at top level; the second failed in stopifnot(), and the handler quit with status 3. R 4.4.0 added a documented option for the opposite wish, to carry on after an error: with options(catch.script.errors = TRUE) the errors of a non-interactive script are printed and the script continues with the next top-level expression, and the status is 0. That suits a notebook-like run in which the output of every step is wanted, and is dangerous in a pipeline, because nothing signals the failure.

# catch-script-errors.R: keep going after an error (R 4.4.0 and later).
options(catch.script.errors = TRUE)
cat("first\n")
stop("the first problem")
cat("second\n")
log("a")
cat("third\n")
Rscript catch-script-errors.R
echo "exit status: $?"
#> first
#> Error: the first problem
#> second
#> Error in log("a") : non-numeric argument to mathematical function
#> third
#> exit status: 0

What still runs on the way out

quit() and an uncaught error are different exits, and the clean-up code that runs differs. The first script calls quit() from inside a function with an on.exit() handler and a tryCatch(finally =); the second raises an error in a function with the same two. The finally clause and the on.exit() handler run on the error path, because the error unwinds the stack; they do not run on the quit() path, because quit() ends the process without unwinding. The .Last() function and a finalizer registered with onexit = TRUE run on quit(); after an error, .Last() does not (the default handler uses runLast = FALSE).

# quit-cleanup.R: what still runs when quit() is called inside a function.
tmp <- tempfile(fileext = ".txt")
writeLines("scratch data", tmp)

e <- new.env()
invisible(reg.finalizer(e, \(env) cat("finalizer ran\n"), onexit = TRUE))

.Last <- function() cat(".Last() ran\n")

leave <- function(status) {
  on.exit(cat("on.exit handler ran\n"))
  tryCatch(
    quit(save = "no", status = status),
    finally = cat("finally ran\n")
  )
}

cat("file exists before quit:", file.exists(tmp), "\n")
leave(5L)
Rscript quit-cleanup.R
echo "exit status: $?"
#> file exists before quit: TRUE
#> .Last() ran
#> finalizer ran
#> exit status: 5
# error-cleanup.R: what still runs when an error reaches the top level.
.Last <- function() cat(".Last() ran\n")

load_data <- function() stop("the data are unusable")

work <- function() {
  on.exit(cat("on.exit handler ran\n"))
  tryCatch(load_data(), finally = cat("finally ran\n"))
}

work()
Rscript error-cleanup.R
echo "exit status: $?"
#> Error in load_data() : the data are unusable
#> Calls: work -> tryCatch -> tryCatchList -> load_data
#> finally ran
#> on.exit handler ran
#> Execution halted
#> exit status: 1

The lesson for a script that must clean up a temporary file or a lock: do not call quit() from inside the code that owns the resource. Let an error propagate (or return a status), clean up with on.exit() or finally, and call quit(status = ) once, at the outermost level, as the report script on this page does. Code in a package should never call quit(): it should signal a condition with stop() and let the script decide the exit status.

stop() or quit(status =)?

Situation Use Why

A failure inside a function that a caller could handle

stop(), preferably with a classed condition

The caller can catch it with tryCatch(), finally and on.exit() run, and a plain script still ends with status 1.

A script that distinguishes kinds of failure for its caller (usage versus data)

tryCatch() at the top, then one quit(status = n)

The status is chosen in one place; the clean-up code has run.

"Quit now, nothing to clean up", at the top of a script

quit(save = "no", status = n)

No handler can intercept it, and it ends the process at once.

The script is loaded with source() from a session

Neither

quit() would end the whole session; signal an error instead.

Standard output and standard error

A process has two output streams, and keeping them apart is what lets a shell redirect one and show the other. Standard output is the result: the report, the CSV, the number someone will read or pipe on. Standard error is everything else: progress, diagnostics, warnings, usage messages. R follows the split, and the table shows which function writes where.

Function Stream Remark

cat(), print(), writeLines(), write.csv(), autoprinted values

standard output

cat(file = stderr()) and writeLines(con = stderr()) write the same text to standard error.

message(), packageStartupMessage()

standard error

Signals a condition of class message; the default handler writes to stderr().

warning()

standard error

Deferred to the end of the top-level call (or printed at once with options(warn = 1)), as "Warning message:".

stop() and R’s own errors

standard error

Printed as "Error in call : message", then "Execution halted".

The script writes one line to each stream in each way. Run on a terminal, the order is the order of the calls.

# streams.R: which function writes to which stream.
cat("result: cat() writes to standard output\n")
print(1:3)
writeLines("result: writeLines() writes to standard output", stdout())
message("diagnostic: message() writes to standard error")
cat("diagnostic: cat(file = stderr())\n", file = stderr())
writeLines("diagnostic: writeLines(con = stderr())", stderr())
warning("diagnostic: a warning goes to standard error", call. = FALSE)
Rscript streams.R
#> result: cat() writes to standard output
#> [1] 1 2 3
#> result: writeLines() writes to standard output
#> diagnostic: message() writes to standard error
#> diagnostic: cat(file = stderr())
#> diagnostic: writeLines(con = stderr())
#> Warning message:
#> diagnostic: a warning goes to standard error

Redirections show the separation. > sends standard output to a file, 2> standard error, 2>&1 joins the two into one destination, and /dev/null discards a stream. With standard output redirected, the diagnostics still reach the terminal; with standard error redirected, the results do.

Rscript streams.R > out.txt
echo "--- out.txt:"
cat out.txt
#> diagnostic: message() writes to standard error
#> diagnostic: cat(file = stderr())
#> diagnostic: writeLines(con = stderr())
#> Warning message:
#> diagnostic: a warning goes to standard error
#> --- out.txt:
#> result: cat() writes to standard output
#> [1] 1 2 3
#> result: writeLines() writes to standard output
Rscript streams.R 2> err.txt
echo "--- err.txt:"
cat err.txt
#> result: cat() writes to standard output
#> [1] 1 2 3
#> result: writeLines() writes to standard output
#> --- err.txt:
#> diagnostic: message() writes to standard error
#> diagnostic: cat(file = stderr())
#> diagnostic: writeLines(con = stderr())
#> Warning message:
#> diagnostic: a warning goes to standard error
Rscript streams.R > /dev/null
#> diagnostic: message() writes to standard error
#> diagnostic: cat(file = stderr())
#> diagnostic: writeLines(con = stderr())
#> Warning message:
#> diagnostic: a warning goes to standard error
Rscript streams.R 2> /dev/null
#> result: cat() writes to standard output
#> [1] 1 2 3
#> result: writeLines() writes to standard output
Rscript streams.R > both.txt 2>&1
cat both.txt
#> result: cat() writes to standard output
#> [1] 1 2 3
#> result: writeLines() writes to standard output
#> diagnostic: message() writes to standard error
#> diagnostic: cat(file = stderr())
#> diagnostic: writeLines(con = stderr())
#> Warning message:
#> diagnostic: a warning goes to standard error

Each command’s output follows its lines: the first printed the diagnostics (standard error, which stayed on the terminal) and the file received the results, the second the other way round, the third and fourth discarded one stream, and the last put both in one file, where the lines happen to be in the order of the calls. When two streams share one file or pipe, the order depends on how each is buffered, so a script should not rely on it. A practical rule follows: results to standard output, everything about the run to standard error, so that Rscript job.R > result.csv leaves a clean file and a log on the terminal. See Streams & Exit Codes for the same rule applied to any command.

sink() and capture.output()

sink(file) diverts standard output to a file until sink() is called again; it does not affect the message stream. sink(con, type = "message") diverts messages, but only to an already open connection, and the help page warns that this should be done "with great care": only one message sink can exist, and a script that fails while it is active loses its diagnostics. For a short block, capture.output() returns the output as a character vector and is safer, because the stream is restored when it returns; capture.output(type = "message") does the same for messages.

# sink.R: divert standard output to a file, then restore it.
log_file <- tempfile(fileext = ".log")
sink(log_file)
print("diverted to the file")
cat("also diverted\n")
message("messages are not diverted by sink()")
sink()
cat("back on the console\n")
readLines(log_file)

# The message stream needs an already open connection and its own sink() call.
con <- file(log_file, open = "wt")
sink(con, type = "message")
message("now the message goes to the file")
sink(type = "message")
close(con)
readLines(log_file)

This script needs no arguments and writes nothing to the current directory (the log file is a temporary file whose name is never printed). Its run on a terminal:

Rscript sink.R
#> messages are not diverted by sink()
#> back on the console
#> [1] "[1] \"diverted to the file\"" "also diverted"
#> [1] "now the message goes to the file"

Reading standard input

A program that reads data from standard input fits into a pipeline: generate | Rscript clean.R | sort. R has two things called "standard input", and mixing them up is the most common error on this subject. The help page of Rscript ends with the rule that stdin() refers to the input file, and file("stdin") to the stdin file stream of the process. stdin() is the connection to the console, and when R reads a script from a file, that file is the console (this allows data lines after the code, a traditional usage that "An Introduction to R" describes). file("stdin") is the C-level standard input of the process, the pipe or file the shell connected. The first script puts data lines after the code on purpose, so it is not valid R and is the one script of this group that is not linted; the second reads the pipe.

# stdin-console.R: stdin() is the script, not the pipe.
lines <- readLines(stdin(), n = 1L)
this line belongs to the data
cat("read:", lines, "\n")
printf 'a\nb\nc\n' | Rscript stdin-console.R
#> read: this line belongs to the data

The three lines a, b, c were ignored; readLines(stdin(), n = 1) read the next line of the script itself. To read the data that was piped in, use file("stdin").

# stdin-lines.R: read every line of the process's standard input.
con <- file("stdin")
lines <- readLines(con)
close(con)
cat("read", length(lines), "lines\n")
print(toupper(lines))
printf 'a\nb\nc\n' | Rscript stdin-lines.R
#> read 3 lines
#> [1] "A" "B" "C"

Four details matter in practice.

  • An unopened connection is opened and closed by each call. readLines(file("stdin"), n = 1) twice does not read two lines: the first call opens the connection, reads from a buffered stream and closes it, and the buffered text is gone, so the second call finds nothing. To read in pieces, open the connection once, as stdin-chunks.R does, and close it at the end.

  • A string is a file name. readLines("stdin") and scan("stdin") pass the name to file(), which treats the name "stdin" as the process’s standard input; "An Introduction to R" uses that form. Spelling file("stdin") out is clearer.

  • No newline at the end. A last line without a line terminator is read, with the warning "incomplete final line found on 'stdin'". Empty input gives character(0) and no error.

  • A terminal waits. If nothing is piped in and the script is started from a terminal, file("stdin") waits for typed lines until end-of-file (Ctrl-D on Unix, Ctrl-Z then Enter on Windows). A script meant to be used both ways checks whether input was given.

# stdin-chunks.R: read standard input a few lines at a time.
con <- file("stdin")
open(con)
first <- readLines(con, n = 2L)
rest <- readLines(con)
close(con)
cat("first chunk:", first, "\n")
cat("the rest:", rest, "\n")
# stdin-unopened.R: an unopened connection is opened and closed by each call.
first <- readLines(file("stdin"), n = 1L)
second <- readLines(file("stdin"), n = 1L)
cat("first: ", first, "\n")
cat("second:", second, "\n")
printf 'a\nb\nc\nd\n' | Rscript stdin-chunks.R
#> first chunk: a b
#> the rest: c d
printf 'a\nb\nc\nd\n' | Rscript stdin-unopened.R
#> first:  a
#> second:
Rscript stdin-lines.R < /dev/null
#> read 0 lines
#> character(0)
printf 'no newline' | Rscript stdin-lines.R
#> Warning message:
#> In readLines(con) : incomplete final line found on 'stdin'
#> read 1 lines
#> [1] "NO NEWLINE"

The transcript is four commands: the chunked read gets both chunks, the unopened version loses the second line (it prints second: with nothing after it), the empty input gives character(0), and the last command prints its warning before its result because the warning belongs to the readLines() call, which finished first.

Tables and numbers from a pipe

The parsers that read a file also read a connection, so read.csv(file("stdin")) and scan(file("stdin")) turn a pipe into a data frame or a vector. read.csv() closes the connection when it finishes. The first script reads the first four lines of loans.csv through a pipe and keeps the columns as text; the second reads numbers and, in its call, uses the string form of the name.

# stdin-csv.R: parse CSV and numbers from standard input.
loans <- read.csv(file("stdin"), colClasses = "character")
cat("rows:", nrow(loans), " columns:", ncol(loans), "\n")
print(head(loans, 3L))
# stdin-scan.R: read whitespace-separated numbers from standard input.
x <- scan("stdin", quiet = TRUE)
cat("n =", length(x), " mean =", mean(x), "\n")
head -n 4 loans.csv | Rscript stdin-csv.R
#> rows: 3  columns: 5
#>   loan_id          isbn     member  loan_date return_date
#> 1    L001 9780141439518 Hugo Silva 2025-01-14  2025-02-04
#> 2    L002 9780306406157 Ben Okafor 2025-01-16  2025-01-26
#> 3    L003 9780002000291  Dev Patel 2025-01-21  2025-01-22
printf '1 2 3\n4 5\n' | Rscript stdin-scan.R
#> n = 5  mean = 3

A pipe is read once, forward only: a script cannot rewind it (file("stdin") has not been considered seekable since R 3.5.1, according to its NEWS), so data needed twice must be stored first. For the full treatment of the connection objects see Connections and the File System; for parsing options of the text readers, Reading and Writing Text Data.

Writing to a pipe

The output side of a pipeline needs no special code: what the script writes to standard output goes to the next program. The script prints each non-empty member name of loans.csv, and the shell does the rest: sort | uniq -c | sort -rn | head counts and ranks them. Spellings in the data differ (Ana Ruiz and a lower-case variant with a trailing blank are one member, cleaned elsewhere), which would show lower in the ranking (the transcript keeps the first four lines).

# lines-to-stdout.R: print each member once, sorted by the shell.
loans <- read.csv("loans.csv", colClasses = "character")
writeLines(loans$member[loans$member != ""])
Rscript lines-to-stdout.R | sort | uniq -c | sort -rn | head -n 4
#>      10 Hugo Silva
#>      10 Ana Ruiz
#>       9 Chloe Martin
#>       7 Grace Liu

If the reader of the pipe quits early, as head does after enough lines, the next write to a closed pipe signals SIGPIPE. R does not die silently of it: it converts the failure to an error, the script ends with status 1, and the shell reports the status of each stage. The PIPESTATUS array of bash shows it (a POSIX sh has only the status of the last command). This script writes two hundred thousand lines, far more than head reads.

# many-lines.R: write a long listing, one number per line.
for (i in seq_len(200000L)) {
  cat(i, "\n", sep = "")
}
cat("done\n", file = stderr())
bash -c 'Rscript many-lines.R | head -n 2; echo "statuses: ${PIPESTATUS[*]}"'
#> 1
#> 2
#> Error in cat(i, "\n", sep = "") : ignoring SIGPIPE signal
#> Execution halted
#> statuses: 1 0

The first status, 1, is the R script’s, killed by its own error message "ignoring SIGPIPE signal", the second, 0, is the status of head. Whether this matters depends on the pipeline: with set -o pipefail the whole pipeline fails. A tool that is often piped to head should catch the error with tryCatch() and exit quietly, or flush results in a way that tolerates a closed reader. The reasoning behind SIGPIPE in command-line tools is in Streams & Exit Codes.

R CMD BATCH, R -f and R with redirected input

R CMD BATCH is the oldest way to run a file and the one "An Introduction to R" names first. It runs R with the file as input and writes the commands and their output, standard error included, to an output file: file.Rout by default, or the second argument. The help page gives the contract: the default options are --restore --save --no-readline, the commands are echoed unless --no-echo or options(echo = FALSE) says otherwise, and a final proc.time() runs after the script unless --no-timing is given or the script calls q(runLast = FALSE) or is aborted. Options go before the file name, and "If infile starts with a -, use — as the final option". The environment variable R_BATCH_OPTIONS adds options after the defaults. This is the script used for the next transcripts. It reads books.csv, prints a value and a table, echoes its arguments and ends with an assignment that prints nothing.

# batch.R: a small script for the batch interfaces.
args <- commandArgs(trailingOnly = TRUE)
books <- read.csv("books.csv")
x <- nrow(books)
x
print(table(books$genre)[1:2])
cat("arguments:", args, "\n")
y <- x * 2

The shape of the output file is the point. --quiet leaves out the start-up banner (version, copyright, platform, licence and help hints), which would otherwise open the file; every line of the script is echoed after the prompt (a > and a space), a value follows the line that produced it, and the file ends with the proc.time() call and its three timings, left out in the transcript below as ... because they change on every run.

R CMD BATCH --quiet --vanilla batch.R
ls -A | grep -c RData
cat batch.Rout
#> 0
#> > # batch.R: a small script for the batch interfaces.
#> > args <- commandArgs(trailingOnly = TRUE)
#> > books <- read.csv("books.csv")
#> > x <- nrow(books)
#> > x
#> [1] 16
#> > print(table(books$genre)[1:2])
#>
#> Classic fiction
#>       2       1
#> > cat("arguments:", args, "\n")
#> arguments:
#> > y <- x * 2
#> >
#> > proc.time()
#>    user  system elapsed
#>   ...     ...     ...

The first line of output is the count of workspace files (zero, with --vanilla). The three differences from Rscript that cause trouble are these.

  • The workspace is saved and restored. With the default --restore --save, R CMD BATCH batch.R writes the final workspace to .RData in the working directory, and the next batch run in that directory loads it silently (with --quiet there is not even the "[Previously saved workspace restored]" line). A bug that depends on an object left by an earlier run then does not appear.

  • Errors go into the file. The status of the command is 1 after an error, but the message is in file.Rout, not on the terminal, so a wrapper has to look at the file or the status. The error message is the last thing in the file, before "Execution halted".

  • Arguments are one string. Extra arguments are passed as "--args one two", a single quoted word, which cannot carry an argument with a space.

# uses-undefined.R: use an object that this script never creates.
cat("x is", x, "\n")
R CMD BATCH --quiet batch.R
ls -A | grep -E '^(batch.Rout|.RData)$'
#> batch.Rout
#> .RData
R CMD BATCH --quiet batch.R
R CMD BATCH --quiet --no-timing uses-undefined.R
cat uses-undefined.Rout
Rscript uses-undefined.R
#> > # uses-undefined.R: use an object that this script never creates.
#> > cat("x is", x, "\n")
#> x is 16
#> >
#> Error: object 'x' not found
#> Execution halted

The first command lists the two files that the default run left behind. The second run of the sequence found the x that batch.R had saved and printed x is 16; Rscript on the same file correctly fails with object 'x' not found. Use --vanilla (or --no-save --no-restore) with R CMD BATCH, as above, or use Rscript. The error case, with the banner left out of the transcript, shows the status and the file:

R CMD BATCH --no-timing --vanilla error-status.R
echo $?
cat error-status.Rout
#> 1
#> [start-up banner left out: version, copyright, platform, licence and help hints]
#>
#> > # error-status.R: an uncaught error ends the run.
#> > cat("before the error\n")
#> before the error
#> > total <- sum(c(1, 2, 3)) + "a"
#> Error in sum(c(1, 2, 3)) + "a" : non-numeric argument to binary operator
#> Execution halted

Arguments and an explicit output file:

R CMD BATCH --quiet --no-timing --vanilla "--args one two" batch.R report-run.Rout
cat report-run.Rout
#> > # batch.R: a small script for the batch interfaces.
#> > args <- commandArgs(trailingOnly = TRUE)
#> > books <- read.csv("books.csv")
#> > x <- nrow(books)
#> > x
#> [1] 16
#> > print(table(books$genre)[1:2])
#>
#> Classic fiction
#>       2       1
#> > cat("arguments:", args, "\n")
#> arguments: one two
#> > y <- x * 2
#> >

The environment-variable help page says that R_BATCH is "set in a batch session, that is one started by R CMD BATCH"; a test with R 4.6.1 on Linux did not find it set, so do not use it to detect a batch run. The R for Windows FAQ answers "Can I use R CMD BATCH?" with yes and also shows the equivalent Rterm.exe --no-restore --no-save < file > file.out 2>&1 as a batch file (R for Windows FAQ 2.11).

R -f, --file and input redirection

R itself accepts the same job in three ways. -f FILE and --file=FILE read the commands from the file, with or without echo; redirecting standard input (R < file) reads them as if typed, and the stdin() of that session is the input. --quiet (or -q) removes the banner, --no-echo (-s) removes the echo, and --args ends R’s options as before. --vanilla is the simplest way to rule out start-up files and a saved workspace.

R --quiet --vanilla -f batch.R --args a b
#> > # batch.R: a small script for the batch interfaces.
#> > args <- commandArgs(trailingOnly = TRUE)
#> > books <- read.csv("books.csv")
#> > x <- nrow(books)
#> > x
#> [1] 16
#> > print(table(books$genre)[1:2])
#>
#> Classic fiction
#>       2       1
#> > cat("arguments:", args, "\n")
#> arguments: a b
#> > y <- x * 2
#> >
R --no-echo --vanilla --file=batch.R --args "two words"
#> [1] 16
#>
#> Classic fiction
#>       2       1
#> arguments: two words
R --quiet --vanilla < batch.R
#> > # batch.R: a small script for the batch interfaces.
#> > args <- commandArgs(trailingOnly = TRUE)
#> > books <- read.csv("books.csv")
#> > x <- nrow(books)
#> > x
#> [1] 16
#> > print(table(books$genre)[1:2])
#>
#> Classic fiction
#>       2       1
#> > cat("arguments:", args, "\n")
#> arguments:
#> > y <- x * 2
#> >
R --quiet --vanilla -e '1 + 1'
#> > 1 + 1
#> [1] 2
#> >

The third run was given no arguments, so args is empty there, and -e echoes the expression with its prompt, unlike Rscript -e. Rscript is what remains when the echo, the banner and the workspace are all unwanted: it is R --no-echo --no-restore --file= with a friendlier command line.

source() versus running a script

A file run with Rscript is read and evaluated one top-level expression at a time, and the value of each is autoprinted unless it is invisible. source() evaluates the expressions of a file inside a running session, and the differences are the ones R FAQ 7.16 gives for the question "Why is the output not printed when I source() a file?": values are not printed unless print.eval = TRUE or echo = TRUE is given. The next script has five top-level expressions and a function whose last value is visible. The first transcript runs it as a script; the second calls source() on it, which prints nothing; the third calls source() with echo = TRUE, which shows each expression and the value of the visible ones.

# autoprint.R: top-level values are printed by Rscript but not by source().
x <- 6 * 7
x
invisible(x)
(y <- x + 1)
if (x > 40) "big" else "small"
f <- function() {
  x
  "last value of f()"
}
f()
Rscript autoprint.R
#> [1] 42
#> [1] 43
#> [1] "big"
#> [1] "last value of f()"
Rscript -e 'source("autoprint.R")'
Rscript -e 'source("autoprint.R", echo = TRUE)'
#>
#> > x <- 6 * 7
#>
#> > x
#> [1] 42
#>
#> > invisible(x)
#>
#> > (y <- x + 1)
#> [1] 43
#>
#> > if (x > 40) "big" else "small"
#> [1] "big"
#>
#> > f <- function() {
#> +     x
#> +     "last value of f()"
#> + }
#>
#> > f()
#> [1] "last value of f()"

Other differences follow from the process model. source() runs in the process that called it, so commandArgs() and interactive() describe that process, not the file, the --file= argument is absent, and a quit() inside the sourced file ends the whole session. sys.source() evaluates in a chosen environment. A script that defines functions and also runs when executed can be written so that it does both jobs: a main guard runs main() only when the file is the top-level script, which is when sys.nframe() is zero, the analogue of Python’s if __name__ == "__main__". When another file calls source() on it, the call stack is not empty and only the definitions are loaded, which is what a test file wants.

# main-guard.R: run main() only when this file is the script, not when sourced.
main <- function(args) {
  cat("main() got", length(args), "argument(s):", args, "\n")
  invisible(0L)
}

cat("run as a script:", sys.nframe() == 0L, "\n")
if (sys.nframe() == 0L) {
  main(commandArgs(trailingOnly = TRUE))
}
Rscript main-guard.R a b
#> run as a script: TRUE
#> main() got 2 argument(s): a b
Rscript -e 'source("main-guard.R")'
#> run as a script: FALSE

The guard is a convention that relies on the file being evaluated at top level. For logic that has tests, put the functions in a package and keep the script thin: the script parses arguments, calls package functions and chooses the exit status, as the shelf report does below. See Testing with testthat for testing the functions.

Environment variables

Environment variables are the other channel between a process and its caller, and the right one for a setting that is not a per-run argument: a credential, a path, the locale, a mode. Sys.getenv(x, unset = "", names = NA) reads them; unset = NA makes an unset variable distinguishable from one set to the empty string ("POSIX requires the OS to distinguish, and all known current R platforms do", says the help page). Sys.setenv(NAME = "value") sets variables for the current process and for programs it starts, never for its parent shell; Sys.unsetenv() removes them. The values are always strings. On Windows, setting a variable to "" unsets it, which POSIX systems do not do.

# env.R: read, set and unset environment variables.
Sys.getenv("SHELF_MODE")
Sys.getenv("SHELF_MODE", unset = "report")
Sys.getenv("SHELF_MODE", unset = NA)
Sys.getenv(c("SHELF_MODE", "SHELF_FORMAT"), unset = "(unset)")
Sys.getenv("SHELF_MODE", names = TRUE)

Sys.setenv(SHELF_MODE = "audit", SHELF_FORMAT = "csv")
Sys.getenv("SHELF_MODE")
Sys.getenv(c("SHELF_MODE", "SHELF_FORMAT"))
Sys.unsetenv("SHELF_MODE")
Sys.getenv("SHELF_MODE", unset = NA)

# An empty value and an unset variable look the same unless unset = NA is used.
Sys.setenv(SHELF_MODE = "")
Sys.getenv("SHELF_MODE")
is.na(Sys.getenv("SHELF_MODE", unset = NA))
nzchar(Sys.getenv("SHELF_MODE"))

# Numbers come back as text.
Sys.setenv(SHELF_LOAN_DAYS = "28")
as.integer(Sys.getenv("SHELF_LOAN_DAYS", unset = "21"))
Rscript env.R
#> [1] ""
#> [1] "report"
#> [1] NA
#>   SHELF_MODE SHELF_FORMAT
#>    "(unset)"    "(unset)"
#> SHELF_MODE
#>         ""
#> [1] "audit"
#>   SHELF_MODE SHELF_FORMAT
#>      "audit"        "csv"
#> [1] NA
#> [1] ""
#> [1] FALSE
#> [1] FALSE
#> [1] 28

Credentials

A password, token or key must not be written in a script, in version control or in a command line, where other users see it in the process list and the shell keeps it in its history. The usual pattern is an environment variable that the caller provides, read once, checked, never printed, and with a clear failure when absent. The placeholder credential in these pages is SHELF_DB_PASSWORD. Sys.setenv() inside a script does not make a secret safe: it only moves the literal into the script.

# secret.R: take a credential from the environment, never from the script.
password <- Sys.getenv("SHELF_DB_PASSWORD", unset = NA_character_)
if (is.na(password) || !nzchar(password)) {
  message("secret.R: set SHELF_DB_PASSWORD in the environment")
  quit(save = "no", status = 1L)
}
cat("password found,", nchar(password), "characters\n")
Rscript secret.R
echo $?
#> secret.R: set SHELF_DB_PASSWORD in the environment
#> 1
SHELF_DB_PASSWORD=placeholder-value Rscript secret.R
echo $?
#> password found, 17 characters
#> 0

The second command sets the variable for the one run (a shell assignment before the command, so it is not exported to later commands). Where a value should persist for the user, the right place is .Renviron in the home directory, with restrictive file permissions: lines of the form NAME=value, read at startup, which Sessions, Workspaces and Startup describes. readRenviron(path) reads such a file on demand, and the file format allows ${NAME-default} expansion, quotes like a shell, and comment lines. Scripts that keep their settings in a project file read it with readRenviron() or with a config package and keep that file out of version control. See Configuration & Auth for the precedence of flags, environment and files in any command-line tool.

# read-renviron.R: read variables from an environment file by hand.
env_file <- tempfile(fileext = ".Renviron")
writeLines(c(
  "# settings for the shelf report",
  "SHELF_FORMAT=csv",
  "SHELF_REPORT_DIR=\"${SHELF_BASE-/srv/shelf}/reports\""
), env_file)

Sys.getenv("SHELF_FORMAT", unset = NA)
readRenviron(env_file)
Sys.getenv("SHELF_FORMAT", unset = NA)
Sys.getenv("SHELF_REPORT_DIR")
Rscript read-renviron.R
#> [1] NA
#> [1] "csv"
#> [1] "/srv/shelf/reports"

Variables that R reads

The help page "Environment Variables" and the page of Startup list the variables that matter, and several apply to scripts. Some are read only at start-up, so setting them inside the running script with Sys.setenv() is too late; set them in the shell, in .Renviron or on the command line before the interpreter.

Variable Effect

R_HOME

The top-level directory of the R installation; set by R in every session, and inherited by the processes it starts. Print it only when you need to; R.home() is the function.

R_LIBS, R_LIBS_USER, R_LIBS_SITE

Initial value of .libPaths(). The place for a project library in a job (see Packages, Libraries and Namespaces).

R_ENVIRON_USER, R_ENVIRON

The user and the site environment file to read, instead of ~/.Renviron and Renviron.site.

R_PROFILE_USER, R_PROFILE

The user and the site profile (R code) to run at start-up, instead of .Rprofile and Rprofile.site. Set to an empty or non-existent file to have a child R ignore the profile.

R_DEFAULT_PACKAGES, R_SCRIPT_DEFAULT_PACKAGES

The packages attached at start-up (see above); NULL for none.

R_BATCH_OPTIONS

Extra options for R CMD BATCH.

TMPDIR, TMP, TEMP

Consulted, in that order, for the session’s temporary directory (tempdir()).

TZ

The time zone, consulted as needed. Sys.timezone() documents the formats.

LANG, LC_ALL, LANGUAGE

The locale and the language of messages. LC_ALL overrides the other LC_* variables, and the locale decides how bytes in a script are read and which quotes sQuote() produces.

HOME

The user’s home directory, used to find .Renviron, .Rprofile and ~ in paths.

Two examples show variables that change output. format(x, tz = "") formats a time in the session’s time zone, which TZ sets, and LC_ALL changes the quotes that sQuote() produces. These runs are one-line commands, with the value set for that run only.

TZ=Asia/Tokyo Rscript -e 'format(as.POSIXct("2026-06-30 12:00:00", tz = "UTC"), tz = "", usetz = TRUE)'
TZ=UTC Rscript -e 'format(as.POSIXct("2026-06-30 12:00:00", tz = "UTC"), tz = "", usetz = TRUE)'
#> [1] "2026-06-30 21:00:00 JST"
#> [1] "2026-06-30 12:00:00 UTC"
Rscript -e 'sQuote("x")'
LC_ALL=C Rscript -e 'sQuote("x")'
#> [1] "‘x’"
#> [1] "'x'"

The locale also decides how the bytes of a script are read. A source file with non-ASCII text is read in the encoding of the session, so a UTF-8 file run under the C locale is read as bytes, as the character count shows. Keep scripts in UTF-8 and make sure the job’s locale is a UTF-8 one (for example LANG=en_US.UTF-8, where that locale exists), or escape non-ASCII characters with \u sequences. R 4.2.0 made UTF-8 the native encoding on Windows (UCRT), which removed most of the Windows-specific trouble; Encodings, Locales and Internationalization has the full picture.

# utf8.R: the same file under two locales.
word <- "café"
cat(word, "\n")
nchar(word)
Rscript utf8.R
LC_ALL=C Rscript utf8.R
#> café
#> [1] 4
#> café
#> [1] 5

Starting another R from R

system2(command, args, stdout, stderr, input, env) runs a program and system2("Rscript", c("-e", shQuote(code)), stdout = TRUE) runs another R, collecting standard output as a character vector. The child inherits the environment, including variables set with Sys.setenv(). A child that exits with a non-zero status gives a warning when its output is captured, and the status becomes the "status" attribute of the result; with stdout = FALSE the function returns the status itself. Sys.which("Rscript") finds the program on the PATH (printed here only as nzchar(), because the path is not the point). file.path(R.home("bin"), "Rscript") names the Rscript that belongs to the running R, which is the right choice when several R versions are installed. Use shQuote() for arguments, never paste(): a quoted string is the way to survive spaces and quotes. callr and processx are community packages for the same job with better control; they are named here and not used.

# system2.R: run another R process and capture what it prints.
cat("Rscript found on the PATH:", nzchar(Sys.which("Rscript")), "\n")

Sys.setenv(SHELF_MODE = "child")
code <- "cat(Sys.getenv('SHELF_MODE'), commandArgs(TRUE), '\\n')"
out <- system2("Rscript", c("-e", shQuote(code), "a", "b"), stdout = TRUE)
out

# A failing child: with stdout = TRUE the output is kept and the status
# becomes an attribute (R also warns, which is suppressed here).
failing <- "cat('partial\\n'); quit(status = 4)"
bad <- suppressWarnings(
  system2("Rscript", c("-e", shQuote(failing)), stdout = TRUE)
)
as.vector(bad)
attr(bad, "status")

# Without capturing, system2() returns the exit status.
status <- system2(
  "Rscript", c("-e", shQuote("quit(status = 7)")),
  stdout = FALSE, stderr = FALSE
)
status
Rscript system2.R
#> Rscript found on the PATH: TRUE
#> [1] "child a b "
#> [1] "partial"
#> [1] 4
#> [1] 7

Parsing arguments

A script with more than two or three arguments needs a parser: options with values, flags, defaults, a --help text and a message for a mistake, ending with a non-zero status and the usage text on standard error. The same options are written three ways below, so that they can be compared; the options are the ones of the report script at the end of the page: --today DATE, --genre NAME, --format text|csv, --stdin and zero or more file names.

With base R

A hand-written parser is about thirty lines and has no dependency, which is the reason to prefer it for a short script or a script that must run on any R installation. The parser in parse-base.R accepts --name value, --name=value, flags, positional arguments and -- to end the options; sub() takes the name and the value from --name=value, match.arg() checks the format, and an error signalled with a class (usage_error) is turned into the usage message and exit status 2 by a tryCatch() at the top.

# parse-base.R: a small hand-written option parser (base R only).
usage <- function() {
  cat("Usage: Rscript parse-base.R [--today DATE] [--genre NAME]",
      "[--format text|csv] [--stdin] [FILE ...]\n")
}

usage_error <- function(...) {
  stop(structure(
    class = c("usage_error", "error", "condition"),
    list(message = paste0(...), call = NULL)
  ))
}

parse_options <- function(argv) {
  opts <- list(today = NA_character_, genre = NA_character_,
               format = "text", stdin = FALSE, files = character())
  i <- 1L
  while (i <= length(argv)) {
    arg <- argv[[i]]
    if (arg == "--") {
      opts$files <- c(opts$files, argv[-seq_len(i)])
      break
    }
    if (!startsWith(arg, "--")) {
      opts$files <- c(opts$files, arg)
      i <- i + 1L
      next
    }
    name <- sub("^--([^=]*).*$", "\\1", arg)
    if (!name %in% c("today", "genre", "format", "stdin")) {
      usage_error("unknown option: --", name)
    }
    if (name == "stdin") {
      opts$stdin <- TRUE
    } else if (grepl("=", arg, fixed = TRUE)) {
      opts[[name]] <- sub("^[^=]*=", "", arg)
    } else {
      i <- i + 1L
      if (i > length(argv)) usage_error("option --", name, " needs a value")
      opts[[name]] <- argv[[i]]
    }
    i <- i + 1L
  }
  opts$format <- tryCatch(
    match.arg(opts$format, c("text", "csv")),
    error = \(e) usage_error("--format: ", conditionMessage(e))
  )
  opts
}

argv <- commandArgs(trailingOnly = TRUE)
if (any(argv %in% c("-h", "--help"))) {
  usage()
  quit(save = "no", status = 0L)
}
opts <- tryCatch(parse_options(argv), usage_error = \(e) {
  message("parse-base.R: ", conditionMessage(e))
  usage()
  quit(save = "no", status = 2L)
})
str(opts)
Rscript parse-base.R --today 2026-06-30 --genre=fiction books.csv extra
#> List of 5
#>  $ today : chr "2026-06-30"
#>  $ genre : chr "fiction"
#>  $ format: chr "text"
#>  $ stdin : logi FALSE
#>  $ files : chr [1:2] "books.csv" "extra"
Rscript parse-base.R --stdin -- --odd
#> List of 5
#>  $ today : chr NA
#>  $ genre : chr NA
#>  $ format: chr "text"
#>  $ stdin : logi TRUE
#>  $ files : chr "--odd"
Rscript parse-base.R --help
echo $?
#> Usage: Rscript parse-base.R [--today DATE] [--genre NAME] [--format text|csv] [--stdin] [FILE ...]
#> 0
Rscript parse-base.R --colour red
echo $?
#> parse-base.R: unknown option: --colour
#> Usage: Rscript parse-base.R [--today DATE] [--genre NAME] [--format text|csv] [--stdin] [FILE ...]
#> 2
Rscript parse-base.R --format xml
echo $?
#> parse-base.R: --format: 'arg' should be one of “text”, “csv”
#> Usage: Rscript parse-base.R [--today DATE] [--genre NAME] [--format text|csv] [--stdin] [FILE ...]
#> 2
Rscript parse-base.R --genre
echo $?
#> parse-base.R: option --genre needs a value
#> Usage: Rscript parse-base.R [--today DATE] [--genre NAME] [--format text|csv] [--stdin] [FILE ...]
#> 2

The parser accepts exactly the spellings it checks for, so --f csv is an error (no partial matching), and the message from match.arg() is R’s own, with the typographic quotes of a UTF-8 locale. What it lacks is help text generated from the option definitions, short flags, repeated options and type conversion, which is what the packages add.

With optparse

optparse is a community package (CRAN, version 1.8.2 here) whose DESCRIPTION calls it a command line parser inspired by Python’s optparse library, meant for Rscript shebang scripts. An OptionParser holds a list of options made with make_option() (or added with add_option()), and parse_args() returns a list with the options and, with positional_arguments = TRUE, the leftover arguments. It writes the --help text itself, prints it and quits with status 0 when asked, and for a parse error prints the usage and a message and quits with status 1. print_help() prints the text on demand, and %prog in usage is replaced by the script name.

# parse-optparse.R: the same options with optparse (a CRAN package).
library(optparse)

option_list <- list(
  make_option("--today", help = "report date, YYYY-MM-DD"),
  make_option("--genre", help = "only this genre"),
  make_option("--format", default = "text",
              help = "output format: text or csv [default: %default]"),
  make_option("--stdin", action = "store_true", default = FALSE,
              help = "read the loans from standard input")
)
parser <- OptionParser(
  usage = "Rscript %prog [options] [FILE ...]",
  option_list = option_list,
  description = "Print a loan report for the shelf catalogue."
)
parsed <- parse_args(parser, positional_arguments = TRUE)
str(parsed)
Rscript parse-optparse.R --help
echo $?
#> Usage: Rscript parse-optparse.R [options] [FILE ...]
#> Print a loan report for the shelf catalogue.
#>
#> Options:
#> 	--today=TODAY
#> 		report date, YYYY-MM-DD
#>
#> 	--genre=GENRE
#> 		only this genre
#>
#> 	--format=FORMAT
#> 		output format: text or csv [default: text]
#>
#> 	--stdin
#> 		read the loans from standard input
#>
#> 	-h, --help
#> 		Show this help message and exit
#>
#>
#> 0
Rscript parse-optparse.R --today 2026-06-30 --genre=fiction books.csv
#> List of 2
#>  $ options:List of 5
#>   ..$ today : chr "2026-06-30"
#>   ..$ genre : chr "fiction"
#>   ..$ format: chr "text"
#>   ..$ stdin : logi FALSE
#>   ..$ help  : logi FALSE
#>  $ args   : chr "books.csv"
Rscript parse-optparse.R --colour red
echo $?
#> Usage: Rscript parse-optparse.R [options] [FILE ...]
#>
#> parse-optparse.R: error: long flag "colour" is invalid
#> 1

Options that have no value and no default are left out of the list (NULL entries do not exist), so test with is.null(opts$genre). A usage error exits with status 1, not 2. The errors are classed (optparse_parse_error, with optparse_bad_option_error and others below it), so a script whose contract needs another status calls parse_args(print_help_and_exit = FALSE) inside tryCatch() and chooses the status itself; a check with R 4.6.1 returned the classes optparse_bad_option_error, optparse_parse_error, error and condition for an unknown option. The package also provides parse_args2() (the same with positional arguments always on), formatters for the help text and a vignette (optparse vignette).

With argparse

argparse (CRAN, version 2.3.1 here) is a wrapper around Python’s argparse module; it needs a Python 3 interpreter on the PATH, found through the findpython package (1.0.9), and the jsonlite (2.0.0) and R6 (2.6.1) packages. The image used for this page has Python 3.12.3. ArgumentParser() makes the parser, $add_argument() adds options and positional arguments, $parse_args() returns a list. The help text, the error messages and the choices check come from Python, so they look like the output of any Python tool.

# parse-argparse.R: the same options with argparse (needs Python 3).
library(argparse)

parser <- ArgumentParser(
  description = "Print a loan report for the shelf catalogue."
)
parser$add_argument("--today", help = "report date, YYYY-MM-DD")
parser$add_argument("--genre", help = "only this genre")
parser$add_argument("--format", choices = c("text", "csv"), default = "text",
                    help = "output format [default: %(default)s]")
parser$add_argument("--stdin", action = "store_true",
                    help = "read the loans from standard input")
parser$add_argument("files", nargs = "*", help = "CSV files")
args <- parser$parse_args()
str(args)
Rscript parse-argparse.R --help
echo $?
#> usage: parse-argparse.R [-h] [--today TODAY] [--genre GENRE]
#>                         [--format {text,csv}] [--stdin]
#>                         [files ...]
#>
#> Print a loan report for the shelf catalogue.
#>
#> positional arguments:
#>   files                CSV files
#>
#> options:
#>   -h, --help           show this help message and exit
#>   --today TODAY        report date, YYYY-MM-DD
#>   --genre GENRE        only this genre
#>   --format {text,csv}  output format [default: text]
#>   --stdin              read the loans from standard input
#> 0
Rscript parse-argparse.R --today 2026-06-30 --genre=fiction books.csv loans.csv
#> List of 5
#>  $ files : chr [1:2] "books.csv" "loans.csv"
#>  $ format: chr "text"
#>  $ genre : chr "fiction"
#>  $ stdin : logi FALSE
#>  $ today : chr "2026-06-30"
Rscript parse-argparse.R --format xml
echo $?
#> usage: parse-argparse.R [-h] [--today TODAY] [--genre GENRE]
#>                         [--format {text,csv}] [--stdin]
#>                         [files ...]
#> parse-argparse.R: error: argument --format: invalid choice: 'xml' (choose from 'text', 'csv')
#> 1
Rscript parse-argparse.R --gen fiction
#> List of 5
#>  $ files : list()
#>  $ format: chr "text"
#>  $ genre : chr "fiction"
#>  $ stdin : logi FALSE
#>  $ today : NULL

Three behaviours deserve attention. Python’s argparse accepts an unambiguous abbreviation of an option, so --gen is --genre; abbreviations make scripts fragile when options are added later, and ArgumentParser(allow_abbrev = FALSE) turns them off. Options not given are NULL, and an empty list of positional arguments is list(). The exit status for an error is 1 here too, even though Python’s own argparse uses 2. Without Python on the PATH the failure happens at ArgumentParser() with the message below (reproduced with Python removed from the PATH), which makes the dependency a deployment concern: a container for a script that uses argparse must include Python.

Error in findpython::find_python_cmd(required_modules = required_modules) :
  Couldn't find a sufficient Python binary. If you haven't installed the Python dependency yet please do so. If you have but it isn't on the system path (as is default on Windows) please add it to path or set options('python_cmd'='/path/to/binary')  or set the PYTHON, PYTHON2, or PYTHON3 environmental variables.   Python must have access to the modules: argparse, json | simplejson
Calls: ArgumentParser -> find_python_cmd -> <Anonymous>

Other parsers, and a choice

docopt (CRAN) derives the parser from the usage text itself, which is attractive for a small tool; getopt is an older, lower-level package still found in older scripts; commandArgs() plus match.arg() and switch() remains common for two-argument scripts. All of these are community packages, none is part of R, and none is needed for the example below. A choice that holds for most scripts: base R for up to three arguments; optparse when a documented --help and flags matter and Python is not welcome in the image; argparse when sub-commands (git-style) or Python-style help are wanted and Python is already there.

The shelf report script

The script report.R brings the earlier pieces together: arguments with a parser, input from a file or from standard input, the shelf package for the analysis, results on standard output, diagnostics on standard error and an exit status that says what happened. It loads shelf with library(shelf), so the package of Creating Packages must be installed (R CMD INSTALL shelf_0.1.0.tar.gz, or install.packages() from a repository when it has been published), and it uses three exported functions: clean_books() and clean_loans() tidy the two tables, and loan_summary() returns one row per genre with the number of loans, the number overdue and the rate.

Attaching shelf prints one line on standard error, because its .onAttach() hook calls packageStartupMessage(); the next two commands show that it is a message (it disappears with 2> /dev/null), and the report script wraps library(shelf) in suppressPackageStartupMessages() so that the line does not appear on every run of a tool.

# startup.R: attaching shelf prints its startup message on standard error.
library(shelf)
cat("attached\n")
Rscript startup.R
#> shelf 0.1.0: loan period is 21 days
#> attached
Rscript startup.R 2> /dev/null
#> attached

Choosing statuses

The report script returns 0 for success, 2 for a usage error (an option it does not know, a missing value, a genre that does not exist) and 1 for a data error (a file it cannot read, a file without the columns it needs). The help page of quit() recommends statuses of 10 or more for a script’s own codes, because R uses 1 for an uncaught error and 2 for catastrophic failures, and Rscript itself ends with 2 when the script file cannot be opened, as the earlier transcript showed. This page uses the convention of the shell tools instead (2 for misuse, 1 for any other failure), because it is the one that callers of command-line tools expect and the one the CLI pages in this documentation recommend; the price is that a status of 2 from a caller’s point of view means "usage error or Rscript could not open the script", which is usually the same kind of mistake. A script with several kinds of failure that a caller must distinguish should document them in --help and choose codes of 10 or more, as the help page suggests. A table in Streams & Exit Codes discusses such taxonomies in general.

Status Meaning in report.R

0

A report was written, or --help was shown. Data warnings may still have been printed to standard error.

1

Data error: a books or loans file does not exist, or lacks a required column. Any unexpected R error (a bug) also ends with 1, by R’s default.

2

Usage error: unknown option, missing value, bad --format, missing or malformed --today, unknown --genre, --stdin together with --loans.

The script

# report.R: print a loan report for the shelf catalogue.
#
# Usage: Rscript report.R [--today DATE] [--genre NAME] [--format text|csv]
#                         [--books FILE] [--loans FILE | --stdin]
#
# Exit status: 0 success, 1 data error, 2 usage error.
suppressPackageStartupMessages(library(shelf))

usage <- function(con = stdout()) {
  cat("Usage: Rscript report.R [--today DATE] [--genre NAME]\n",
      "                       [--format text|csv] [--books FILE]\n",
      "                       [--loans FILE | --stdin]\n",
      "Without --today the variable SHELF_TODAY is used.\n",
      sep = "", file = con)
}

condition_class <- function(class, ...) {
  structure(class = c(class, "error", "condition"),
            list(message = paste0(...), call = NULL))
}
usage_error <- function(...) stop(condition_class("usage_error", ...))
data_error <- function(...) stop(condition_class("data_error", ...))

parse_options <- function(argv) {
  opts <- list(today = Sys.getenv("SHELF_TODAY", unset = ""), genre = "",
               format = "text", books = "books.csv", loans = "",
               stdin = FALSE)
  i <- 1L
  while (i <= length(argv)) {
    arg <- argv[[i]]
    name <- sub("^--([^=]*).*$", "\\1", arg)
    if (!startsWith(arg, "--") || !name %in% names(opts)) {
      usage_error("unknown argument: ", arg)
    }
    if (name == "stdin") {
      opts$stdin <- TRUE
    } else if (grepl("=", arg, fixed = TRUE)) {
      opts[[name]] <- sub("^[^=]*=", "", arg)
    } else {
      i <- i + 1L
      if (i > length(argv)) usage_error("option --", name, " needs a value")
      opts[[name]] <- argv[[i]]
    }
    i <- i + 1L
  }
  opts$format <- tryCatch(
    match.arg(opts$format, c("text", "csv")),
    error = \(e) usage_error("--format must be text or csv")
  )
  if (!nzchar(opts$today)) usage_error("--today (or SHELF_TODAY) is required")
  opts$today <- as.Date(opts$today, format = "%Y-%m-%d")
  if (is.na(opts$today)) usage_error("the report date must be YYYY-MM-DD")
  if (opts$stdin && nzchar(opts$loans)) {
    usage_error("--stdin and --loans exclude each other")
  }
  if (!nzchar(opts$loans)) opts$loans <- "loans.csv"
  opts
}

read_table <- function(path, what, columns) {
  if (!identical(path, "stdin") && !file.exists(path)) {
    data_error(what, " file not found: ", path)
  }
  con <- if (identical(path, "stdin")) file("stdin") else path
  table <- read.csv(con, colClasses = "character")
  missing <- setdiff(columns, names(table))
  if (length(missing) > 0L) {
    data_error(what, " data lacks the column(s): ", toString(missing))
  }
  table
}

run <- function(argv) {
  if (any(argv %in% c("-h", "--help"))) {
    usage()
    return(invisible())
  }
  opts <- parse_options(argv)
  books <- read_table(opts$books, "books", c("title", "isbn", "genre"))
  loans <- read_table(if (opts$stdin) "stdin" else opts$loans, "loans",
                      c("loan_id", "isbn", "loan_date", "return_date"))
  books <- clean_books(books)
  loans <- clean_loans(loans)

  report <- loan_summary(loans, books, opts$today)
  if (nzchar(opts$genre)) {
    if (!opts$genre %in% report$genre) {
      usage_error("unknown genre '", opts$genre, "'; known genres: ",
                  toString(report$genre))
    }
    report <- report[report$genre == opts$genre, , drop = FALSE]
  }
  report$overdue_rate <- round(report$overdue_rate, 3)

  n_bad_date <- sum(is.na(loans$loan_date))
  if (n_bad_date > 0L) {
    warning(n_bad_date, " loan(s) have no valid loan date", call. = FALSE)
  }
  n_unknown <- sum(!loans$isbn %in% books$isbn)
  if (n_unknown > 0L) {
    warning(n_unknown, " loan(s) match no book and are left out", call. = FALSE)
  }

  if (opts$format == "csv") {
    write.csv(report, row.names = FALSE)
  } else {
    cat("Loan report for ", format(opts$today), ", loan period ",
        getOption("shelf.loan_days"), " days\n\n", sep = "")
    print(report, row.names = FALSE)
    cat("\nOverdue: ", sum(report$overdue), " of ", sum(report$loans),
        " loans\n", sep = "")
  }
}

status <- tryCatch({
  run(commandArgs(trailingOnly = TRUE))
  0L
}, usage_error = \(e) {
  message("report.R: ", conditionMessage(e))
  usage(stderr())
  2L
}, data_error = \(e) {
  message("report.R: ", conditionMessage(e))
  1L
})
quit(save = "no", status = status)

The structure is the one to copy for any script of this size.

  • Parse, then validate, then work. parse_options() has no side effects; it either returns the options or signals a usage_error. Dates are parsed with as.Date(format = ) and an impossible date such as 2026-02-30 is a usage error, not a quietly wrong report. The flag takes its default from Sys.getenv("SHELF_TODAY"), so an option given on the command line wins, then the environment, then an error: no call of the current date hides inside the report, which is why the output below is reproducible.

  • Classed conditions carry the kind of failure. usage_error and data_error are ordinary conditions with a class added; run() signals them with stop(), and only the outermost tryCatch() decides the status and the message. The code in run() never calls quit().

  • One exit. quit(save = "no", status = status) runs once, at the end, after the handlers have printed to standard error with message() and usage(stderr()). Success is 0L.

  • Data come from a file or from the pipe. read_table() reads a named file, or file("stdin") when --stdin is given, with colClasses = "character" so that ISBNs and dates stay text until clean_books() and clean_loans() handle them, and it checks the required columns before the analysis meets a missing one.

  • Warnings are for data the run could use but should say something about. The number of loans with no valid date and of loans whose ISBN matches no book are counted and reported with warning(call. = FALSE): they go to standard error, they do not change the status, and they print after the results, as R prints deferred warnings after the top-level call.

  • Output has one format switch. --format csv writes write.csv(row.names = FALSE) to standard output, which is the part another program reads; --format text prints a header, the table and a total.

Running it

The first run is the default text report. --today fixes the reference date, 2026-06-30 as everywhere in this documentation, and the loan period of 21 days comes from the option that shelf sets when it is loaded. The two lines under "Warning messages" are on standard error and appear after the report.

Rscript report.R --today 2026-06-30
echo "exit status: $?"
#> Loan report for 2026-06-30, loan period 21 days
#>
#>            genre loans overdue overdue_rate
#>          classic     6       3        0.500
#>          fiction    23      13        0.565
#>          mystery     4       2        0.500
#>      non-fiction     3       2        0.667
#>           poetry     6       4        0.667
#>  science fiction    15       7        0.467
#>
#> Overdue: 31 of 57 loans
#> Warning messages:
#> 1: 1 loan(s) have no valid loan date
#> 2: 3 loan(s) match no book and are left out
#> exit status: 0

--genre restricts the table, and a CSV report is the format for the next program. Here the CSV goes to a pipe and head takes the first lines, while standard error is redirected to a file so that it does not interleave with the pipe’s output (the lines are then read back).

Rscript report.R --today 2026-06-30 --genre fiction
echo "exit status: $?"
#> Loan report for 2026-06-30, loan period 21 days
#>
#>    genre loans overdue overdue_rate
#>  fiction    23      13        0.565
#>
#> Overdue: 13 of 23 loans
#> Warning messages:
#> 1: 1 loan(s) have no valid loan date
#> 2: 3 loan(s) match no book and are left out
#> exit status: 0
Rscript report.R --today 2026-06-30 --format csv 2> warnings.txt | head -n 4
echo '--- warnings.txt:'
cat warnings.txt
#> "genre","loans","overdue","overdue_rate"
#> "classic",6,3,0.5
#> "fiction",23,13,0.565
#> "mystery",4,2,0.5
#> --- warnings.txt:
#> Warning messages:
#> 1: 1 loan(s) have no valid loan date
#> 2: 3 loan(s) match no book and are left out

Redirecting standard output to a file keeps the file clean, because the warnings went to standard error; discarding standard output leaves the warnings on the terminal.

Rscript report.R --today 2026-06-30 --format csv > loan-report.csv
echo $?
wc -l loan-report.csv
#> Warning messages:
#> 1: 1 loan(s) have no valid loan date
#> 2: 3 loan(s) match no book and are left out
#> 0
#> 7 loan-report.csv
Rscript report.R --today 2026-06-30 > /dev/null
#> Warning messages:
#> 1: 1 loan(s) have no valid loan date
#> 2: 3 loan(s) match no book and are left out

The loans can arrive through standard input instead of a file, which lets the report sit at the end of a pipeline (zcat loans.csv.gz | Rscript report.R --stdin), and the date can come from the environment. In a pipeline, the CSV produced by one Rscript is read by another: the last command reads it with read.csv(file("stdin")) and prints one number, with the warnings of the first run discarded.

Rscript report.R --today 2026-06-30 --stdin < loans.csv
echo "exit status: $?"
#> Loan report for 2026-06-30, loan period 21 days
#>
#>            genre loans overdue overdue_rate
#>          classic     6       3        0.500
#>          fiction    23      13        0.565
#>          mystery     4       2        0.500
#>      non-fiction     3       2        0.667
#>           poetry     6       4        0.667
#>  science fiction    15       7        0.467
#>
#> Overdue: 31 of 57 loans
#> Warning messages:
#> 1: 1 loan(s) have no valid loan date
#> 2: 3 loan(s) match no book and are left out
#> exit status: 0
SHELF_TODAY=2026-03-31 Rscript report.R --genre poetry
echo "exit status: $?"
#> Loan report for 2026-03-31, loan period 21 days
#>
#>   genre loans overdue overdue_rate
#>  poetry     6       3          0.5
#>
#> Overdue: 3 of 6 loans
#> Warning messages:
#> 1: 1 loan(s) have no valid loan date
#> 2: 3 loan(s) match no book and are left out
#> exit status: 0
Rscript report.R --today 2026-06-30 --genre fiction --format csv 2> /dev/null |
  Rscript -e 'x <- read.csv(file("stdin")); cat(x$genre, x$overdue_rate, "\n")'
#> fiction 0.565

The failure cases are the other half of the contract. A usage error prints the message, the usage text and no report, with status 2; the usage text is on standard error here because it is part of the error, while --help prints it on standard output and exits with 0.

Rscript report.R --today 2026-06-30 --genre nonsense
echo "exit status: $?"
#> report.R: unknown genre 'nonsense'; known genres: classic, fiction, mystery, non-fiction, poetry, science fiction
#> Usage: Rscript report.R [--today DATE] [--genre NAME]
#>                        [--format text|csv] [--books FILE]
#>                        [--loans FILE | --stdin]
#> Without --today the variable SHELF_TODAY is used.
#> exit status: 2
Rscript report.R
echo "exit status: $?"
#> report.R: --today (or SHELF_TODAY) is required
#> Usage: Rscript report.R [--today DATE] [--genre NAME]
#>                        [--format text|csv] [--books FILE]
#>                        [--loans FILE | --stdin]
#> Without --today the variable SHELF_TODAY is used.
#> exit status: 2
Rscript report.R --today 2026-06-30 --format xml
echo "exit status: $?"
#> report.R: --format must be text or csv
#> Usage: Rscript report.R [--today DATE] [--genre NAME]
#>                        [--format text|csv] [--books FILE]
#>                        [--loans FILE | --stdin]
#> Without --today the variable SHELF_TODAY is used.
#> exit status: 2
Rscript report.R --today 2026-06-30 --stdin --loans loans.csv < loans.csv
echo "exit status: $?"
#> report.R: --stdin and --loans exclude each other
#> Usage: Rscript report.R [--today DATE] [--genre NAME]
#>                        [--format text|csv] [--books FILE]
#>                        [--loans FILE | --stdin]
#> Without --today the variable SHELF_TODAY is used.
#> exit status: 2
Rscript report.R --help
echo "exit status: $?"
#> Usage: Rscript report.R [--today DATE] [--genre NAME]
#>                        [--format text|csv] [--books FILE]
#>                        [--loans FILE | --stdin]
#> Without --today the variable SHELF_TODAY is used.
#> exit status: 0

A data error is status 1 and a one-line message, with no R call stack, because it is a condition the script expects. Passing books.csv as the loans file shows the column check: the file exists and parses, but it is not a loans file.

Rscript report.R --today 2026-06-30 --loans nope.csv
echo "exit status: $?"
#> report.R: loans file not found: nope.csv
#> exit status: 1
Rscript report.R --today 2026-06-30 --loans books.csv
echo "exit status: $?"
#> report.R: loans data lacks the column(s): loan_id, loan_date, return_date
#> exit status: 1

Compare the same mistake without the script’s own check, with a bare read.csv() on a file that does not exist. R reports the error with the call that failed (file, reached through read.csv and read.table) and adds the warning that holds the real reason, as a line that starts "In addition". Without an argument at all the error is a subscript error from args[[1L]], evaluated lazily inside read.table().

# read-missing.R: read the CSV file named on the command line.
args <- commandArgs(trailingOnly = TRUE)
loans <- read.csv(args[[1L]])
cat(nrow(loans), "rows\n")
Rscript read-missing.R nope.csv
echo "exit status: $?"
#> Error in file(file, "rt") : cannot open the connection
#> Calls: read.csv -> read.table -> file
#> In addition: Warning message:
#> In file(file, "rt") :
#>   cannot open file 'nope.csv': No such file or directory
#> Execution halted
#> exit status: 1
Rscript read-missing.R
echo "exit status: $?"
#> Error in args[[1L]] : subscript out of bounds
#> Calls: read.csv -> read.table
#> Execution halted
#> exit status: 1

To make report.R a program rather than an argument to Rscript, add #!/usr/bin/env Rscript as its first line and run chmod +x report.R; a scheduler such as cron should call Rscript with the full path of the script and set SHELF_TODAY or --today itself, and a container that runs it needs shelf installed in its library (see Docker and Reproducible Environments with renv). Testing the script’s functions belongs in the package’s tests; testing the script itself means running it and checking the status and the streams, as Testing CLIs describes, with system2() from a testthat test as the R-side tool.

Common errors and warnings

The messages below were produced by the scripts of this page, except where a line says otherwise. Three of the scripts are the smallest possible examples: one with an unfinished statement and one with a typing mistake, which are not valid R and so are not linted, and one that attaches a package that is not installed.

# incomplete.R: the last statement is not finished.
x <- (1 +
# syntax-error.R: a typing mistake.
x <- 1
y <- x +* 2
# missing-package.R: attach a package that is not installed.
library(notinstalledpkg)
cat("never reached\n")
Rscript incomplete.R
echo "exit status: $?"
#> Error: unexpected end of input
#> Execution halted
#> exit status: 1
Rscript syntax-error.R
echo "exit status: $?"
#> Error: unexpected '*' in "y <- x +*"
#> Execution halted
#> exit status: 1
Rscript missing-package.R
echo "exit status: $?"
#> Error in library(notinstalledpkg) :
#>   there is no package called ‘notinstalledpkg’
#> Execution halted
#> exit status: 1
Message Cause Fix

Fatal error: cannot open file 'nope.R': No such file or directory (status 2)

The script named after Rscript does not exist in the current directory.

Check the path and the working directory; use the full path in cron.

WARNING: unknown option '--nonsense'

An option was written before the script name, where Rscript reads it; it is ignored and the run goes on.

Put script options after the file name.

ERROR: option '-e' requires a non-empty argument (status 1)

Rscript -e '' or an unquoted expression that the shell split.

Quote the expression for your shell.

Error: unexpected end of input

The last statement of the script is unfinished (an open bracket or an operator at the end).

Close the statement; reported as a parse error since R 3.2.1.

Error: unexpected '*' in "y <- x +*"

A typing mistake: the script is parsed up to the point of the error, and earlier lines have run.

Fix the line; lint the script before running it.

Error in library(notinstalledpkg) : there is no package called ‘notinstalledpkg’

The library of the job lacks the package; R_LIBS_USER, .libPaths() or the container differ from the one used at development.

Install the package for the job, or set R_LIBS (see Packages, Libraries and Namespaces).

Error in file(file, "rt") : cannot open the connection with cannot open file 'nope.csv': No such file or directory

A file argument that does not exist, or a relative path read from another directory.

Check file.exists() first and report a data error.

Error in args[[1L]] : subscript out of bounds

No argument was given and the script indexed commandArgs(trailingOnly = TRUE).

Test length(args), or use a parser.

Error: (converted from warning) NAs introduced by coercion

options(warn = 2) is active and a conversion produced NA.

Validate the input, or handle the warning with tryCatch().

Warning message: In readLines(con) : incomplete final line found on 'stdin'

The last line of the input has no line terminator.

Harmless for readLines(); set warn = FALSE or add the newline upstream.

Error in cat(i, "\n", sep = "") : ignoring SIGPIPE signal

The program after the pipe (head) closed it early.

Expected for head; catch the error or accept status 1 of that stage.

Error: object 'x' not found (under Rscript) but x is 16 (under R CMD BATCH)

A saved .RData supplied x to the batch run.

Run with --vanilla, and delete the stray .RData.

env: 'Rscript --vanilla': No such file or directory

A shebang with env and an option: the kernel passes Rscript --vanilla as one word.

Use env -S, or drop the option, or hard-code the path.

env: 'Rscript\r': No such file or directory

The file has Windows line endings.

Convert to LF.

Error in findpython::find_python_cmd(required_modules = required_modules) : and Couldn't find a sufficient Python binary

argparse cannot find Python 3 on the PATH.

Install Python, or use optparse or base R.

bash: Rscript: command not found (the wording is the shell’s own)

Printed by the shell, not R: Rscript is not on the PATH of this shell or job.

Add R’s bin directory to the PATH, or give the full path.

Pitfalls

  • Printing instead of returning. print() and autoprinting add [1] prefixes and quotes, and print(x) of a data frame pads the columns. For output that another program will read, use cat(), writeLines() or write.csv(), and fix the format of numbers (format(), sprintf()), since the number of digits printed depends on options(digits) and the decimal mark on options(OutDec).

  • Plots in a script. A plot opened by a script goes to a file, Rplots.pdf in the working directory, because the default device of a non-interactive session is pdf(). Open the device you want explicitly (see Base Graphics and Devices); R FAQ 7.19 explains the same for PNG files in batch mode.

  • The working directory is the caller’s. A relative path in a script is relative to where the command was started, not to the script. Take paths from arguments, or from the script’s own directory (--file=), or from a project-root helper, and avoid changing the working directory inside a script.

  • Start-up files change the result. .Rprofile and .Renviron apply to every run; a job on another machine behaves differently. --vanilla removes the variation, and a pinned set of packages (renv, a container) removes the rest.

  • Waiting for input. A script that reads file("stdin") and is started from a terminal without a pipe waits for the user; readline() returns "" at once. Neither is an error, and both look like a hang or a wrong result.

  • Exit status of a pipeline. The status of a pipeline a | b is the status of b unless set -o pipefail (bash) is set; check the status of the stage that matters.

  • Warnings do not fail. A script that must fail on bad data checks its conditions and calls stop(), or sets options(warn = 2).

  • Secrets in arguments. Arguments appear in process listings and shell history. Use the environment or a file with restricted permissions.

  • quit() in the middle. It skips on.exit() and finally, and it ends a session that loaded the file with source().

Version notes

Version Change relevant to this page

R 3.2.1

Rscript and command-line R report an incomplete statement at the end of a script as a parse error instead of silently ignoring it (R 3.x NEWS).

R 3.4.0

Reading from file("stdin") closes the connection, which fixed a memory leak when reading from a full pipe (R 3.x NEWS).

R 3.5.0

Rscript uses the same default packages as R (it attached all but methods before), reads R_SCRIPT_DEFAULT_PACKAGES first, offers R_SCRIPT_LEGACY to restore the old behaviour for a time, and accepts several arguments on the #! line (R 3.x NEWS).

R 3.5.1

file("stdin") is no longer considered seekable, and the file argument of Rscript may contain spaces (R 3.x NEWS).

R 4.0.0

R --help documents --no-echo ("renamed from --slave") and its short form -s; --slave still works (R 4.x NEWS).

R 4.2.0

The native encoding on Windows is UTF-8 (UCRT builds), which removes most encoding differences between a script on Windows and on Linux (R 4.x NEWS).

R 4.2.2

Rscript -e no longer fails when standard input is closed (R 4.x NEWS).

R 4.4.0

options(catch.script.errors = TRUE) is a documented way to catch errors and continue in a non-interactive run (R 4.x NEWS).

R 4.6.1

All the behaviour shown on this page was observed with this release. A search of the release NEWS for Rscript, commandArgs, quit and R CMD BATCH finds no entry after 4.4.0 that changes it.

Where older tutorials differ

Older advice What to do now

R --slave --no-save < script.R, or R CMD BATCH script.R as the way to run a script

Rscript script.R. --slave is the old name of --no-echo and still works; R CMD BATCH saves and restores .RData by default, so it needs --vanilla. "An Introduction to R" still names R CMD BATCH first, but Rscript is the front end for arguments and shebang lines.

#!/usr/bin/Rscript or any hard-coded path

#!/usr/bin/env Rscript, which finds Rscript on the PATH.

args <- commandArgs(TRUE) with positional access and no checks, or commandArgs()[6]

commandArgs(trailingOnly = TRUE), a length check, and a parser for options. Index arithmetic on the full vector breaks with --vanilla.

library(methods) at the top of every script

Needed before R 3.5.0, when Rscript did not attach methods; harmless now.

source("script.R") to run a script, or source(echo = TRUE) to see results

Run it with Rscript. source() is for loading code into a session, and its autoprint rules differ.

The getopt package for options

getopt is still on CRAN; optparse (or a short base parser) is what newer scripts use.

A setwd call built from sys.frame(1)$ofile to find the script’s directory

The --file= argument of commandArgs(), or a project-root helper such as here (community packages).

Sys.setenv(DB_PASSWORD = "...") at the top of a script

Read the credential from the environment that the caller provides (Sys.getenv()); never write it in a file under version control.

print("done") or cat("error\n") for diagnostics

message() for diagnostics, which goes to standard error, and a non-zero quit(status = ) or stop() for failures.

system(paste("Rscript", f))

system2("Rscript", shQuote(f), stdout = TRUE), which captures the output and quotes the argument.

Where to go next

The design of any command-line tool, whatever it is written in, is the subject of Building CLIs for AI Agents; the pages that matter most for an R script are Streams & Exit Codes (the stdout/stderr split and the exit-code taxonomy used above), Self-Describing Help (what --help should say), Machine-Readable Output (the CSV and JSON side of --format), Configuration & Auth (flags, environment variables, files and credentials), Testing CLIs (checking status, streams and --help) and Packaging & Distribution (getting a tool onto other machines). Within R: Sessions, Workspaces and Startup for the files a script run reads, Conditions and Error Handling and Defensive Programming and Input Validation for the failures a script turns into statuses, Creating Packages for the shelf package the report uses, Testing with testthat, Reports with Quarto and R Markdown for a report that is a document instead of a table, and Shiny, plumber and Web APIs for R programs that stay running.