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? |
|---|---|---|
|
Runs |
No |
|
The same, with one or more expressions instead of a file. |
No |
|
Runs the file with |
Yes, in the output file |
|
Runs the file in the ordinary |
Yes, unless |
|
Reads the commands from standard input, as if typed; |
Yes |
|
One expression, echoed like typed input. |
Yes |
|
Inside a running session; no |
Only with |
Source: An Introduction to R: Invoking R
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.
Source: R documentation: Rscript
Rscript --version prints the version banner and exits.
Rscript --version
#> Rscript (R) version 4.6.1 (2026-06-24)
Source: R documentation: Rscript
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"
Source: R documentation: Rscript
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))
Source: R documentation: commandArgs
So Rscript args.R a is R --no-echo --no-restore --file=args.R --args a, and the options it adds mean:
| Option | Meaning |
|---|---|
|
Do not echo the commands. |
|
Do not restore a saved |
|
Take the commands from the file. |
|
Everything after this marker is left alone by R and is what |
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"
Source: R documentation: Rscript
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
Source: R documentation: Rscript
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"
Source: R documentation: Rscript
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]"
Source: R documentation: commandArgs
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
Source: R documentation: Rscript
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
Source: R documentation: Rscript
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")
Source: R documentation: Startup
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
Source: R documentation: Rscript
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
Source: R documentation: Rscript
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"
Source: R documentation: commandArgs
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.
3is"3"; a number or a date must be converted by the script, and validated, becauseas.integer("three")isNAwith a warning, not an error. -
With no script arguments there is no
--argsmarker at all andcommandArgs(trailingOnly = TRUE)ischaracter(0), soargs[[1]]fails withsubscript out of bounds; testlength(args)first.
Rscript args.R
#> number of elements: 4
#> [1] "--no-echo" "--no-restore" "--file=args.R"
#> character(0)
Source: R documentation: commandArgs
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")
Source: R documentation: commandArgs
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
Source: R documentation: commandArgs
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. ForR CMD BATCHthe 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-1arrives as"-1". A parser decides what is an option (see Parsing arguments). -
Encodings: the arguments are bytes in the current locale;
Encoding()andenc2utf8()apply as for any string, and the flag--encoding=ENCofRsets 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")
Source: R documentation: interactive
Rscript interactive.R
#> interactive(): FALSE
echo 'interactive()' | R --quiet --no-save
#> > interactive()
#> [1] FALSE
#> >
echo 'interactive()' | R --quiet --no-save --interactive
#> > interactive()
#> [1] TRUE
#> >
Source: R documentation: interactive
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")
Source: R documentation: readline
echo yes | Rscript readline.R
#> Continue?
#> answer: []
#> interactive(): FALSE
Source: R documentation: readline
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")
Source: R documentation: showConnections
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
Source: R documentation: showConnections
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")
Source: R documentation: quit
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
Source: R documentation: quit
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")
Source: R documentation: stop
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
Source: R documentation: stop
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")
Source: R documentation: warning
Rscript warning-status.R
echo "exit status: $?"
#> before the warning
#> Warning message:
#> NAs introduced by coercion
#> after the warning: NA
#> exit status: 0
Source: R documentation: warning
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")
Source: R documentation: options
Rscript warn-as-error.R
echo "exit status: $?"
#> reading the year
#> Error: (converted from warning) NAs introduced by coercion
#> Execution halted
#> exit status: 1
Source: R documentation: options
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")
Source: R documentation: options
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
Source: R documentation: options
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")
Source: R NEWS: Changes in R 4.4.0
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
Source: R NEWS: Changes in R 4.4.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)
Source: R documentation: quit
Rscript quit-cleanup.R
echo "exit status: $?"
#> file exists before quit: TRUE
#> .Last() ran
#> finalizer ran
#> exit status: 5
Source: R documentation: quit
# 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()
Source: R documentation: on.exit
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
Source: R documentation: on.exit
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 |
|
The caller can catch it with |
A script that distinguishes kinds of failure for its caller (usage versus data) |
|
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 |
|
No handler can intercept it, and it ends the process at once. |
The script is loaded with |
Neither |
|
Source: R documentation: quit
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 |
|---|---|---|
|
standard output |
|
|
standard error |
Signals a condition of class |
|
standard error |
Deferred to the end of the top-level call (or printed at once with |
|
standard error |
Printed as "Error in call : message", then "Execution halted". |
Source: R documentation: message
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)
Source: R documentation: showConnections
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
Source: R documentation: message
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
Source: R documentation: showConnections
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)
Source: R documentation: sink
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"
Source: R documentation: sink
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
Source: R documentation: showConnections
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))
Source: R documentation: readLines
printf 'a\nb\nc\n' | Rscript stdin-lines.R
#> read 3 lines
#> [1] "A" "B" "C"
Source: R documentation: readLines
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, asstdin-chunks.Rdoes, and close it at the end. -
A string is a file name.
readLines("stdin")andscan("stdin")pass the name tofile(), which treats the name"stdin"as the process’s standard input; "An Introduction to R" uses that form. Spellingfile("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")
Source: R documentation: connections
# 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")
Source: R documentation: connections
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"
Source: R documentation: readLines
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))
Source: R documentation: read.table
# stdin-scan.R: read whitespace-separated numbers from standard input.
x <- scan("stdin", quiet = TRUE)
cat("n =", length(x), " mean =", mean(x), "\n")
Source: R documentation: scan
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
Source: R documentation: scan
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 != ""])
Source: R documentation: writeLines
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
Source: R documentation: writeLines
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())
Source: R documentation: cat
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
Source: R documentation: cat
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
Source: R documentation: BATCH
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
#> ... ... ...
Source: R documentation: BATCH
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.Rwrites the final workspace to.RDatain the working directory, and the next batch run in that directory loads it silently (with--quietthere 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")
Source: R documentation: BATCH
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
Source: R documentation: BATCH
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
Source: R documentation: BATCH
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()"
Source: R documentation: source
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))
}
Source: R documentation: sys.parent
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
Source: R documentation: sys.parent
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"))
Source: R documentation: Sys.getenv
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
Source: R documentation: Sys.setenv
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")
Source: R documentation: Sys.getenv
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
Source: R documentation: Sys.getenv
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")
Source: R documentation: readRenviron
Rscript read-renviron.R
#> [1] NA
#> [1] "csv"
#> [1] "/srv/shelf/reports"
Source: R documentation: Startup
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 |
|---|---|
|
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; |
|
Initial value of |
|
The user and the site environment file to read, instead of |
|
The user and the site profile (R code) to run at start-up, instead of |
|
The packages attached at start-up (see above); |
|
Extra options for |
|
Consulted, in that order, for the session’s temporary directory ( |
|
The time zone, consulted as needed. |
|
The locale and the language of messages. |
|
The user’s home directory, used to find |
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'"
Source: R documentation: timezones
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)
Source: R documentation: locales
Rscript utf8.R
LC_ALL=C Rscript utf8.R
#> café
#> [1] 4
#> café
#> [1] 5
Source: R documentation: locales
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
Source: R documentation: system2
Rscript system2.R
#> Rscript found on the PATH: TRUE
#> [1] "child a b "
#> [1] "partial"
#> [1] 4
#> [1] 7
Source: R documentation: system2
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)
Source: R documentation: match.arg
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
Source: R documentation: match.arg
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")
Source: R documentation: message
Rscript startup.R
#> shelf 0.1.0: loan period is 21 days
#> attached
Rscript startup.R 2> /dev/null
#> attached
Source: R documentation: message
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 |
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 |
Source: R documentation: quit
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)
Source: R documentation: quit
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 ausage_error. Dates are parsed withas.Date(format = )and an impossible date such as2026-02-30is a usage error, not a quietly wrong report. The flag takes its default fromSys.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_erroranddata_errorare ordinary conditions with a class added;run()signals them withstop(), and only the outermosttryCatch()decides the status and the message. The code inrun()never callsquit(). -
One exit.
quit(save = "no", status = status)runs once, at the end, after the handlers have printed to standard error withmessage()andusage(stderr()). Success is0L. -
Data come from a file or from the pipe.
read_table()reads a named file, orfile("stdin")when--stdinis given, withcolClasses = "character"so that ISBNs and dates stay text untilclean_books()andclean_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 csvwriteswrite.csv(row.names = FALSE)to standard output, which is the part another program reads;--format textprints 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
Source: R documentation: commandArgs
--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
Source: R documentation: commandArgs
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
Source: R documentation: message
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
Source: R documentation: connections
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
Source: R documentation: stop
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
Source: R documentation: stop
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")
Source: R documentation: read.table
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
Source: R documentation: read.table
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 +
Source: R documentation: Rscript
# syntax-error.R: a typing mistake.
x <- 1
y <- x +* 2
Source: R documentation: Rscript
# missing-package.R: attach a package that is not installed.
library(notinstalledpkg)
cat("never reached\n")
Source: R documentation: library
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
Source: R documentation: library
| Message | Cause | Fix |
|---|---|---|
|
The script named after |
Check the path and the working directory; use the full path in cron. |
|
An option was written before the script name, where |
Put script options after the file name. |
|
|
Quote the expression for your shell. |
|
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. |
|
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. |
|
The library of the job lacks the package; |
Install the package for the job, or set |
|
A file argument that does not exist, or a relative path read from another directory. |
Check |
|
No argument was given and the script indexed |
Test |
|
|
Validate the input, or handle the warning with |
|
The last line of the input has no line terminator. |
Harmless for |
|
The program after the pipe ( |
Expected for |
|
A saved |
Run with |
|
A shebang with |
Use |
|
The file has Windows line endings. |
Convert to LF. |
|
argparse cannot find Python 3 on the |
Install Python, or use optparse or base R. |
|
Printed by the shell, not R: |
Add R’s |
Source: R documentation: Rscript
Pitfalls
-
Printing instead of returning.
print()and autoprinting add[1]prefixes and quotes, andprint(x)of a data frame pads the columns. For output that another program will read, usecat(),writeLines()orwrite.csv(), and fix the format of numbers (format(),sprintf()), since the number of digits printed depends onoptions(digits)and the decimal mark onoptions(OutDec). -
Plots in a script. A plot opened by a script goes to a file,
Rplots.pdfin the working directory, because the default device of a non-interactive session ispdf(). 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.
.Rprofileand.Renvironapply to every run; a job on another machine behaves differently.--vanillaremoves 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 | bis the status ofbunlessset -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 setsoptions(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 skipson.exit()andfinally, and it ends a session that loaded the file withsource().
Version notes
| Version | Change relevant to this page |
|---|---|
R 3.2.1 |
|
R 3.4.0 |
Reading from |
R 3.5.0 |
|
R 3.5.1 |
|
R 4.0.0 |
|
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 |
|
R 4.4.0 |
|
R 4.6.1 |
All the behaviour shown on this page was observed with this release. A search of the release NEWS for |
Source: R NEWS: Changes in R 4.x
Source: R NEWS: Changes in R 3.x
Where older tutorials differ
| Older advice | What to do now |
|---|---|
|
|
|
|
|
|
|
Needed before R 3.5.0, when |
|
Run it with |
The |
|
A |
The |
|
Read the credential from the environment that the caller provides ( |
|
|
|
|
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.