purrr modify() in R: Transform Elements, Keep the Type
The modify() function in purrr applies a function to each element of a list, vector, or data frame and returns the result with the same type as the input. That type-preservation is the one thing that separates it from map().
modify(df, ~ .x * 2) # each column, returns a data frame modify(list(a = 1, b = 2), ~ .x + 1) # each element, returns a list modify_if(df, is.numeric, round) # only the numeric columns modify_at(df, "x", ~ .x * 100) # only the named columns modify_at(df, c(1, 3), sqrt) # columns picked by position modify2(x, y, `+`) # two inputs, type preserved imodify(df, ~ paste(.y, .x)) # element plus its name or index modify_depth(nested, 2, ~ .x + 1) # modify at a nesting depth
Need explanation? Read on for examples and pitfalls.
What modify() does in one sentence
modify(.x, .f) runs .f on every element of .x and gives you back an object of the same class as .x. Feed it a data frame, you get a data frame. Feed it an integer vector, you get an integer vector.
This is the whole reason modify() exists. map() is the workhorse of purrr, but it always flattens its result into a plain list. When you want the structure you started with, modify() is the type-stable choice.
modify() is map() plus a promise: "the shape goes out the way it came in". Mentally, read modify(x, f) as "apply f element-wise, then put the answers back into a copy of x". That copy-back step is what preserves the data frame, the named list, or the atomic vector.Syntax
modify(.x, .f, ...) takes the object to transform and a function (or ~ lambda) to apply to each element. Extra arguments after .f are passed straight through to it.
The same .x-then-.f signature is shared by every member of the family: modify_if(), modify_at(), modify2(), imodify(), and modify_depth(). Learn one and the rest follow.
The first call returns a numeric vector; the second returns a named list. Same function, same lambda, two different output types, each matching its input.
Five common patterns
1. Transform every column of a data frame
A data frame is a list of columns, so modify() walks it column by column and rebuilds the data frame.
map(df, ...) would have returned a bare list of two vectors. modify() keeps the data.frame class and the row names.
2. Touch only the numeric columns with modify_if
modify_if(.x, .p, .f) applies .f only to elements where the predicate .p returns TRUE. Everything else is left untouched.
The four numeric columns are rounded; the Species factor passes the is.numeric test as FALSE and survives unchanged.
3. Target columns by name with modify_at
When you know exactly which elements to change, modify_at() accepts a character vector of names or an integer vector of positions.
Only the two named columns are scaled. The other three are copied across verbatim.
4. Walk two inputs in parallel with modify2
modify2(.x, .y, .f) is to modify() what map2() is to map(): it steps through two objects together, exposing .x and .y inside the lambda.
The result is a numeric vector because prices (the .x argument) was a numeric vector. modify2() always copies its answer back into the type of .x.
5. Use the element name with imodify
imodify() is the indexed variant. Inside the lambda, .x is the value and .y is the name (or the integer position when the input is unnamed).
You get a named list back, exactly the structure of scores, with each value rewritten using its own name.
modify() beats lapply() and a manual as.data.frame() rebuild. It does the transform and the type restoration in one call, so you never lose column names, row names, or the data.frame class along the way.modify() vs map() vs lapply()
All three apply a function element-wise; they differ in what type they hand back. That difference decides which one belongs in your pipeline.
| Function | Returns | Type-preserving? |
|---|---|---|
purrr::modify() |
Same class as .x |
Yes |
purrr::map() |
Always a plain list | No |
base::lapply() |
Always a plain list | No |
base::sapply() |
Simplified, varies by input | Inconsistent |
The decision rule is short. Reach for modify() when you want the input structure preserved, especially data frames. Reach for map() or its typed cousins (map_dbl(), map_chr()) when a list or a specific atomic vector is the goal. Avoid sapply() in production code because its return type is unpredictable.
Common pitfalls
Pitfall 1: modify() does not change the original in place. R uses copy-on-modify, so modify(x, ~ .x + 1) builds and returns a new object while x itself is untouched. The name "modify" describes the type-preserving behaviour, not mutation. You must reassign with x <- modify(x, ...) to keep the result.
Pitfall 2: returning a new type silently coerces atomic vectors. When .x is an atomic vector, modify() writes each answer back with [[<-. If your .f returns a different type, the whole vector is coerced. modify(c(1, 2, 3), ~ as.character(.x)) quietly returns a character vector, so the type-preservation promise can break under you.
Pitfall 3: modify() on a data frame is column-wise, never row-wise. Each "element" of a data frame is a full column. If you expected the function to see one row at a time, you will get the wrong answer. For row-wise work, use dplyr::rowwise() or purrr::pmap() instead.
.f must return a value the container can hold. For a list, anything goes. For an atomic vector, .f should return a length-1 value of a compatible type per element, or you trigger coercion (Pitfall 2) or a length error. When in doubt, transform into a list first.Try it yourself
Try it: Use modify_if() to double only the numeric columns of df_mix, leaving the character name column alone. Save the result to ex_modified.
Click to reveal solution
Explanation: modify_if() runs is.numeric() on each column and applies the lambda only where it returns TRUE. The name column fails the test and is copied through unchanged, while the data frame class is preserved.
Related purrr functions
Once modify() clicks, the neighbouring functions extend it:
map(): the same element-wise idea, but it always returns a plain list.modify_if()andmodify_at(): conditional and positional variants ofmodify().modify_depth(): reaches into nested lists and modifies elements at a chosen depth.list_modify(): a different operation entirely, it replaces, adds, or removes list items by name.map2()andimap(): the list-returning cousins ofmodify2()andimodify().
The official reference for the whole family lives at purrr.tidyverse.org.
FAQ
What is the difference between map() and modify() in purrr?
map() always returns a plain list, no matter what you pass in. modify() returns an object of the same class as its input: a data frame stays a data frame, an atomic vector stays an atomic vector. Both apply the function element-wise in exactly the same way. Choose modify() when preserving the input structure matters, and map() when a list is what you actually want.
Does purrr modify() change the original object?
No. R follows copy-on-modify semantics, so modify() builds a new object and returns it while the original is left untouched. Despite the name, it does not mutate anything in place. To keep the transformed result, assign it back, for example x <- modify(x, ~ .x + 1).
How do I modify only some columns of a data frame in R?
Use modify_if() with a predicate when the columns are defined by a test, such as modify_if(df, is.numeric, round). Use modify_at() when you know the exact column names or positions, such as modify_at(df, c("x", "y"), sqrt). Both leave the remaining columns and the data frame class intact.
What does modify_at() do in purrr?
modify_at(.x, .at, .f) applies .f only to the elements named or positioned in .at, and copies every other element across unchanged. It is the targeted version of modify(): instead of transforming everything, you pick the exact slots. The return type still matches the input, so a data frame in gives a data frame out.