Guides··9 min read

paste in R Programming: How to Join Strings Properly

R has no + operator for text, so paste() is how you join strings. The function itself is one line to learn. What actually costs people an afternoon is the rest of it: sep and collapse do two different jobs, vectors get recycled without a warning, and NA quietly becomes the two-character string "NA". This page walks through all of it with code you can run, and says plainly where sprintf() or cat() is the better tool.

By Deepak Yadav, I write R most days

The short version

  • sep joins sideways across the arguments you pass. collapse joins downward across the resulting vector. Nearly every paste bug is picking the wrong one.
  • paste() recycles short vectors to the length of the longest and does not warn about it, so check length() when the output looks odd.
  • It coerces everything with as.character(), which means NA turns into text and doubles arrive with more decimals than the console showed you.

#What paste() does in R

paste() is base R's string concatenation function. It converts every argument to character, joins them element by element with a separator, and returns a character vector. No package is needed, and it has been in R since the beginning. The full signature is short:

paste(..., sep = " ", collapse = NULL, recycle0 = FALSE)

Three of those four arguments cover almost everything you will ever do with paste in R programming. Here is the whole vocabulary in one table:

ArgumentDefaultWhat it does
...noneAny number of vectors, each converted with as.character()
sep" "Goes between the arguments, position by position
collapseNULLFlattens the result vector into one string
recycle0FALSEWhen TRUE, a zero-length argument makes the whole result character(0)

recycle0 was added in R 4.0.1. If you are on an older R it is not there.

# the default separator is one space
paste("Hello", "world")              #> "Hello world"

# choose your own
paste("Hello", "world", sep = ", ")  #> "Hello, world"

# paste0 is paste with sep = ""
paste0("Hello", "world")             #> "Helloworld"

The coercion step is worth pausing on, because it is where the surprises come from. paste() does not care what type you hand it. Numbers, logicals, factors and dates all go through as.character() first, and the result is whatever that function decides.

paste("n =", 42)                  #> "n = 42"
paste("flag:", TRUE)              #> "flag: TRUE"
paste("date:", Sys.Date())        #> "date: 2026-09-04"

f <- factor(c("low", "high"))
paste("level:", f)                #> "level: low"  "level: high"

That factor line catches people who expect the integer codes underneath. as.character() on a factor gives you the labels, so paste() gives you the labels too. Which is almost always what you wanted, but it is worth knowing it is a decision the function made for you rather than a coincidence.

#sep and collapse are two different jobs

If you only take one thing from this page, take this. sep works sideways, collapse works downward. They are not two flavours of the same setting, and mixing them up produces a result with the wrong length rather than an error, which is why it can survive all the way into a report.

x <- c("a", "b", "c")
y <- 1:3

# sep: join x[i] to y[i]. Three inputs in, three strings out.
paste(x, y, sep = "-")
#> "a-1" "b-2" "c-3"

# collapse: take that vector and squash it into one string
paste(x, y, sep = "-", collapse = ", ")
#> "a-1, b-2, c-3"

# collapse alone, on a single vector
paste(x, collapse = "")
#> "abc"

The diagnostic is length(). If paste() handed you a vector of three when you wanted one sentence, you needed collapse. If it handed you one long string when you wanted a column, you passed collapse by mistake. Nothing else changes the length of the output.

A shortcut worth knowing: toString(x) is exactly paste(x, collapse = ", "). If comma-space is your separator, the shorter name reads better.

One more detail that trips up people writing report text. When you use collapse, sep still applies first, so the two compose: sep glues the arguments, then collapse glues the result. Reading it in that order makes the nested case obvious instead of mysterious.

#Recycling happens silently

paste() is vectorised, which is the good news. You almost never need a loop to build a column of strings. Every argument is recycled up to the length of the longest one, so a scalar prefix repeats for free:

paste0("plot_", 1:3, ".png")
#> "plot_1.png" "plot_2.png" "plot_3.png"

The bad news is that recycling is not checked for you. Arithmetic in R warns when lengths do not divide evenly. paste() does not warn at all. It just cycles round and hands you plausible-looking rubbish:

paste(c("a", "b", "c", "d"), c("x", "y"))
#> "a x" "b y" "c x" "d y"

paste(1:3, 1:2)
#> "1 1" "2 2" "3 1"    (no warning, no error)

I have shipped that bug. The label column looked fine at a glance because every value was a well-formed string, and the mismatch only showed up when someone sorted by it. If two vectors going into a paste come from different places, assert the shapes match before you join them:

stopifnot(length(first) == length(second))
labels <- paste(first, second, sep = " / ")

Inside a data frame this problem mostly disappears, because every column is the same length by construction. That is a real argument for doing string assembly in a mutate() or a transform() call rather than on loose vectors sitting in your global environment.

#NA, NULL and empty input

This section is the one to bookmark. Three edge cases account for most of the paste questions on Stack Overflow, and all three come from the same place: paste() has to return a character vector, so it converts things that are not strings into strings.

NA becomes the text "NA"

There is no argument to change this. A missing value goes in, the two characters N and A come out, and from then on it is real text that is.na() will not find:

paste("value:", NA)      #> "value: NA"
paste0("id", NA)         #> "idNA"

is.na(paste0("id", NA))  #> FALSE

Two honest fixes. Replace the missing values before you paste, with ifelse(is.na(x), "", x) or an explicit default. Or use stringr::str_c(), which propagates missingness the way most people expect: if any input element is NA, that output element is NA rather than a string containing the letters.

library(stringr)
str_c("value: ", NA)     #> NA

Neither is more correct than the other. base paste() is saying "you asked for text, here is text". str_c() is saying "missing plus anything is missing". Pick the one that matches what a wrong value would cost you downstream, and stay consistent inside a project.

NULL disappears, but the separator does not

A zero-length argument is treated as an empty string rather than dropped, so the separator around it still gets inserted. The double space below is not a typo:

paste("foo", NULL, "bar")     #> "foo  bar"
paste0("foo", NULL, "bar")    #> "foobar"

# R 4.0.1 and later: opt out of that behaviour
paste("foo", NULL, "bar", recycle0 = TRUE)
#> character(0)

This bites when a variable is conditionally empty. A prefix that is NULL on some code paths leaves a stray leading space in your file names, and file names with leading spaces are their own long afternoon.

An empty vector is not an empty string

paste(character(0)) returns character(0), not "". If the next function expects one string it will fail on the empty case, which of course only happens in production when a filter returns no rows. Adding collapse fixes it, because collapsing nothing gives you an empty string:

paste(character(0))                  #> character(0)
paste(character(0), collapse = ", ") #> ""

#paste versus cat, sprintf and glue

The most common real question is not paste versus paste0. It is paste versus cat, and the answer is that they are not alternatives at all. paste() returns a value. cat() prints and returns nothing.

s <- paste("rows:", 120)
s                       #> [1] "rows: 120"

cat("rows:", 120, "\n")
#> rows: 120

out <- cat("rows:", 120, "\n")
out                     #> NULL

That last line is the bug people hit. cat() returns NULL invisibly, so assigning its result gives you NULL, and any test written against it fails in a confusing way. Build the string with paste() or sprintf(), then hand it to cat() only when the point is to print.

Two smaller cat() details. Its default separator is also a single space, which is why cat("done", "\n") prints a space before the newline; write cat("done\n") instead. And in a script or a package, message() is usually the better printer: it writes to stderr, adds the newline for you, and can be silenced with suppressMessages(), which cat() cannot.

FunctionReturnsVectorisedUse it for
paste()character vectorYesBuilding strings you will store or reuse
paste0()character vectorYesThe same, with no separator
cat()NULL (invisibly)Flattens everythingPrinting to the console right now
message()NULL (invisibly)Flattens everythingStatus text in scripts and packages
sprintf()character vectorYesAnything with number formatting
glue::glue()glue / characterYesReadable interpolation of many variables

sprintf() deserves more use than it gets. paste() has no opinion about decimal places, so money and percentages come out wrong unless you round first. sprintf() puts the shape of the output in one readable template:

paste0("Total: $", 5)              #> "Total: $5"
sprintf("Total: $%.2f", 5)         #> "Total: $5.00"
sprintf("%.1f%% of %d rows", 12.345, 800)
#> "12.3% of 800 rows"

Once a message has three or more variables in it, glue() from the glue package usually reads best, because the variables appear in the sentence where they belong instead of as a queue of arguments. It is an extra dependency, so it is a judgement call for packages and a no-brainer for analysis scripts.

#Where paste earns its keep, and where it does not

Four jobs where paste is the right answer, and three where reaching for it is a mistake.

Generating names. Column names, object names, list keys. The vectorised form does the whole set in one line:

names(df) <- paste0("v", seq_along(df))
#> "v1" "v2" "v3" ...

Numbered output files. Pad the counter so the files sort correctly in Finder, in your shell and in list.files(). This is the tip I wish I had learned earlier, because renaming 200 badly sorted plots is not a good afternoon:

# sorts wrong: plot_1, plot_10, plot_2
paste0("plot_", 1:12, ".png")

# sorts right: plot_001, plot_002, ... plot_012
paste0("plot_", sprintf("%03d", 1:12), ".png")

Human-readable summaries. collapse is what turns a vector into a sentence: paste0("Dropped ", length(bad), " columns: ", toString(bad)).

Plot labels and titles. Anywhere ggplot2 or base graphics wants a single string built from your data.

Now the three places to stop. File paths. Use file.path(), which inserts the right separator and does not leave you with a double slash when a variable already ends in one:

file.path("data", "raw", "file.csv")   #> "data/raw/file.csv"

SQL. Do not paste values into a query. Beyond the injection risk, it breaks the first time a value contains an apostrophe or a date needs quoting. Use parameterised queries with DBI::dbBind(), or DBI::sqlInterpolate(), both of which escape properly for the specific database.

Accumulating in a loop. This is the quiet performance trap. Each pass copies the whole string built so far, so the cost grows with the square of the number of iterations:

# slow: rebuilds the entire string every iteration
out <- ""
for (w in words) out <- paste0(out, w, " ")

# fast: one allocation, one join
out <- paste(words, collapse = " ")

On a hundred words you will never notice. On a hundred thousand you will notice a lot. The general rule in R holds here: collect into a vector, then combine once at the end.

One last coercion note. paste() formats doubles with as.character(), which does not follow your options(digits) setting, so a number that printed as 0.333 in the console can arrive in your string with a long tail of decimals. Round explicitly, or use sprintf(), whenever the number is going in front of a person.

#If you meant the other kind of paste

Quick disambiguation, since this site is mostly about Macs and the word does double duty. If you arrived looking for the keyboard command rather than the function, how to copy on Mac covers the shortcuts, and the clipboard on Mac explains where the copied thing actually lives and how to keep more than one at a time.

The overlap is not purely a pun. I spend a lot of the R workday moving snippets between the console, a script and a message to somebody, and macOS keeps exactly one clipboard entry by default, so the second copy destroys the first. A clipboard history removes that particular papercut. I compared the dedicated tools in the Paste clipboard manager review. None of it changes a line of your R code, but it changes how many times a day you re-run a chunk because you lost the output.

#Frequently asked questions

Why is paste used in R programming?

Because R has no + operator for strings. paste() is the base R way to join text: it converts every argument to character, glues them together with a separator, and returns a character vector. It is used for building file names and paths, generating column names, labelling plots, assembling log and status messages, and flattening a vector into one readable string with collapse.

What is the difference between the paste() and paste0() functions in R?

Only the default separator. paste() uses sep = " ", so it puts one space between the pieces. paste0() is defined as paste(..., sep = ""), so it puts nothing between them. Everything else is identical, including recycling, the collapse argument and the way NA becomes the string "NA", because paste0() calls paste() internally. Use paste0() for file names, paths and column names, and paste() for text a human will read.

What is the difference between the cat() and paste() functions in R?

paste() builds a string and returns it, so you can store it, test it or pass it on. cat() prints to the console and returns NULL invisibly, so there is nothing to store. paste() is a value-producing function, cat() is a side effect. If you assign the result of cat() to a variable you get NULL, which is a common beginner bug. Use paste() or sprintf() to build the text, then cat() or message() only when you actually want it printed.

What does cat() do in R?

cat() concatenates its arguments and writes them to the console or a connection. It prints raw text with no quotation marks and no [1] index, and it interprets escapes such as \n as a real newline. Its default separator is a single space, which is why cat("done", "\n") prints a space before the line break. cat() returns NULL invisibly and cannot print lists or data frames usefully.

How do I stop paste from turning NA into the string NA?

paste() always coerces NA to the two characters N and A, and there is no argument to change that. Either replace the missing values before pasting, for example with ifelse(is.na(x), "", x), or use stringr::str_c(), which propagates NA instead: if any input is NA the result for that element is NA rather than a string containing "NA".

Written from years of writing R, not from a product. Every example here is meant to run as printed; if one does not, email hello@thedeepflux.com and I will correct the page.
Deepak YadavCrafting beautiful digital consumer products.

Product designer and indie hacker. Founder of Ossian Design Lab. Builds and ships business and consumer digital products in public.

Follow on X

Read next.

Look up.
It's all right there.