Python Interop with reticulate

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. This page is about one add-on package, reticulate 1.47.0 (CRAN, published 2026-09-03), and the Python it embeds: Python 3.12.3 with NumPy 2.5.4 and pandas 3.0.6, all installed in the same image as R. One example also names the bit64 package (4.8.6) to show something reticulate does not understand, and the lintr 3.4.0 default linters were used only to check the scripts. PyArrow is not installed, so the pandas string columns on this page are the NumPy-backed str kind (see What pandas 3.0 changed). The reticulate package puts a Python interpreter inside the R process. R code calls Python functions, reads and sets Python attributes, passes R vectors, matrices and data frames to Python and gets the results back, with the conversion done for you in both directions; a Python exception arrives as an R condition and an R function can be handed to Python as a callback. There is no second process and no file or socket in between: values are converted in memory, which is why the rules about which Python is used, how values are converted and who owns the memory are worth learning.

Every example is a script that was run with Rscript on R 4.6.1 with warnings printed as they happen (options(warn = 1)), and the lines that start with #> are what it printed. The scripts also pass the default lintr linters. The scripts were run with the RETICULATE_PYTHON environment variable pointing at the Python 3.12 that has NumPy and pandas installed, so none of them contains a use_python() line: on your machine the interpreter is chosen by one of the means in Choosing a Python, and the interpreter path is never printed here (where a transcript would show it, it is replaced by a placeholder and the page says so). The names books, loans and the other objects of the running library-shelf scenario come from the section index: a script that needs them starts with source("scenario.R") or reads books.csv directly. The one script that sources a Python file (isbn.py, shown in full) runs in a directory that holds the file next to books.csv. Nothing on this page needs the network to be correct: py_require(), py_install(), virtualenv_create() and install_miniconda() download things, so they appear only as fragments, labelled as such, and were not run. Where a message contains quotation marks, they are the typographic quotes R prints in a UTF-8 locale.

This page belongs to the performance and interoperability group, next to Interfacing C, C++ and Fortran, which is the same job done through compiled code and the C API of R. reticulate is the opposite direction from the Python Reference: it assumes you already know Python, so start with the Python Reference for the language itself and with Virtual Environments and Packaging for the environments that Choosing a Python refers to. The data structures on the R side are described in Data Frames, Matrices and Arrays and The tidyverse and dplyr; the Rust page Python Interop with PyO3 is the same idea seen from the other language (The same idea elsewhere: embedding and extending).

How reticulate works

An R session that loads reticulate can start one Python interpreter, in the same process, and it can start only one: Python is initialised the first time something needs it, and from then on the interpreter, the modules it imported and the objects it created live until the R session ends. The word the package documentation uses is lazy: the bindings are not initialised until an API that explicitly requires Python is called, which is what lets a script, or a package, ask for a particular Python before the first call (reticulate package documentation: use_python). py_available() reports whether that has happened yet, and py_config() forces it.

Three consequences follow. First, an interpreter must be found, and it must have been built with a shared library (the --enable-shared option of Python’s own build; the versions article says reticulate can only bind to such a build), so choosing the Python is the first decision and the subject of Choosing a Python. Second, every Python object you hold in R is a reference. An R object of class python.builtin.module, python.builtin.function or python.builtin.dict is a small R environment wrapping a pointer to a Python object, and printing it prints the Python repr(); the R class vector lists the Python class and its parents, as When a module is missing shows for an exception. Third, values cross the boundary by conversion, in both directions, with rules (Type conversion) that are mostly intuitive and have a handful of sharp edges that are worth knowing in advance: integers and doubles are different Python types, NA has no Python equivalent, a vector is a list, a matrix is an array that shares memory with R, and a data frame is a pandas DataFrame.

The usual first program is two lines. The package documentation’s first example imports the Python os module and lists a directory, and the shape is always the same: import() a module, call its functions with $. The block is that example, shown as a fragment so that its output does not depend on the contents of the directory it is run in; the examples that follow are real scripts.

library(reticulate)
os <- import("os")
os$listdir(".")

Choosing a Python

reticulate can bind to almost any Python installation on the machine, and since reticulate 1.41 it can also create one for you. The decision matters because the Python packages you need (NumPy, pandas) are installed per interpreter or per environment, and because the choice is made once per R session: after Python has been initialised the interpreter cannot be changed. The tools, in the order a project would reach for them today, are py_require() (declare what you need and let reticulate build an environment), the use_*() functions and the RETICULATE_PYTHON environment variable (name an interpreter or environment you manage yourself), and the commands that create environments (virtualenv_create(), conda_create()).

Declaring requirements with py_require()

py_require() was added in reticulate 1.41.0 (2025-02-24): its NEWS entry calls it a new function for declaring Python requirements for the current R session. The vignette on installing Python packages now presents it as the recommended way to declare Python dependencies, a call comparable to library() for Python packages, and the vignette on Python versions says that selecting an interpreter by hand has been generally unnecessary, and no longer recommended, since 1.41. A call updates a manifest that reticulate keeps for the session: Python packages (with pip-style version constraints), a constraint on the Python version itself, and an optional exclude_newer date that limits package versions to those published before it, which is a lightweight alternative to freezing versions. Nothing is installed at that point. The manifest is consulted when Python is first initialised, typically at the first import(), and only if no interpreter that you manage was found earlier in the order of discovery (The order of discovery); reticulate then resolves the manifest into an ephemeral virtual environment, cached by the exact set of requirements, so a second run with the same requirements reuses it.

reticulate uses uv (the Python package and project manager) for this resolution. If uv is already on the machine reticulate uses it as it is; if not, it downloads uv into the directory returned by tools::R_user_dir("reticulate", "cache"), together with the Python builds and the ephemeral environments, and clears that cache automatically on an interval that defaults to 120 days (1.44.0) and is set with RETICULATE_MAX_CACHE_AGE_DAYS (1.47.0 added it as an environment variable) or the reticulate.max_cache_age option. Because uv does the work, its environment variables apply: UV_OFFLINE, UV_INDEX, UV_DEFAULT_INDEX, UV_PRERELEASE and UV_PYTHON_PREFERENCE. Two environment variables switch the feature on and off: RETICULATE_USE_MANAGED_VENV="no" disables the ephemeral environment, and RETICULATE_PYTHON="managed" forces it.

The calls go at the top of a script, next to library(), because after Python is initialised only additions are possible: removing a package, changing the Python version or exclude_newer is not. Calling py_require() with no arguments prints the manifest, including which R package or script requested each item, which is how to debug conflicting requirements. This is a fragment: it needs the network the first time, so the page did not run it, and the scripts on this page ran against the system interpreter through RETICULATE_PYTHON, a self-managed choice that makes py_require() irrelevant (the package documentation says the manifest has no effect when another interpreter is found first).

library(reticulate)

# Declare what the script needs, before anything starts Python.
py_require(c("numpy", "pandas"))
py_require(python_version = ">=3.11")
py_require(exclude_newer = "2026-09-01")

# The first use of Python resolves the requirements into a cached
# ephemeral environment (this needs the network the first time).
pd <- import("pandas")

# With no arguments, py_require() prints the current manifest.
py_require()

The reference page describes four arguments: packages (names, with optional constraints such as "jax[cpu]>=0.5", or pip-style git and local-path forms), python_version (for example ">=3.9,<3.13"), exclude_newer (an RFC 3339 timestamp, a date string, a Date or a POSIXct, the last two since 1.47.0) and action, one of "add" (the default), "remove" (exact matches only) and "set" (replace everything). R packages can declare their own requirements the same way, usually from .onLoad(), and the manifest combines everything that was declared (Python in an R package). If the requirements cannot all be satisfied, initialising Python fails with uv’s explanation of the conflict and the current manifest, and the fix is to adjust the requirements with action = "remove" or "set", or to roll back to a known good date with exclude_newer. Since 1.44.0 reticulate warns when a package declared with py_require() is missing from the environment it ended up using; the warning can be silenced with RETICULATE_CHECK_REQUIRED_PACKAGES=0.

Naming an interpreter: use_python(), RETICULATE_PYTHON and friends

When you manage the Python yourself, you tell reticulate which one with one of five functions or one environment variable.

Call What it selects

use_python("<path to python>")

A specific interpreter binary.

use_virtualenv("name-or-path")

A virtual environment, by name (looked up under the virtualenv root, ~/.virtualenvs by default, configurable with WORKON_HOME, and RETICULATE_VIRTUALENV_ROOT since 1.39.0) or by path.

use_condaenv("name")

A conda environment, by name, prefix or interpreter path; conda = names the conda executable.

use_miniconda("name")

A conda environment of the Miniconda that install_miniconda() installed.

use_python_version("3.12")

A Python installed by install_python().

RETICULATE_PYTHON

The path to an interpreter, standalone, in a virtual environment or in a conda environment. It overrides every other request.

All the use_*() functions take required. The default NULL is TRUE in most circumstances: the request is mandatory and an error is raised if the interpreter does not exist; with required = FALSE it is a hint, and reticulate goes on scanning when the hint cannot be satisfied. The calls only last for the R session, so they must be repeated in every script (or set once through the environment variable), and if several are made the most recent wins. They also have to come before the first use of Python. The environment variable is the one to put in ~/.Renviron or in a project’s .Renviron; it is read when Python initialises, and a value that points nowhere is an error rather than a hint. This script asks for an interpreter that does not exist in both ways, and catches the error, which is the real text reticulate raises.

# An explicit request for an interpreter that does not exist is an error,
# whether it comes from use_python(required = TRUE) ...
library(reticulate)
tryCatch(
  use_python("/opt/nowhere/bin/python", required = TRUE),
  error = \(e) conditionMessage(e)
)
#> [1] "Specified version of python '/opt/nowhere/bin/python' does not exist."

# ... or from the RETICULATE_PYTHON environment variable, which wins over
# every use_*() call.
Sys.setenv(RETICULATE_PYTHON = "/opt/nowhere/bin/python")
tryCatch(py_config(), error = \(e) conditionMessage(e))
#> [1] "Python specified in RETICULATE_PYTHON (/opt/nowhere/bin/python) does not exist"

The fragment below shows the persistent way. .Renviron is an ordinary text file of NAME=value lines read at startup, with ${HOME} (the braces are required) available for the home directory (the Startup help page describes it, and Sessions, Workspaces and Startup shows the order of the startup files), so the value applies to every R session started in that project and nothing in the scripts needs to change.

RETICULATE_PYTHON="${HOME}/.virtualenvs/shelf/bin/python"

The order of discovery

When nothing is requested, reticulate walks a fixed list and takes the first interpreter that qualifies. The list below is the one of the Python Version Configuration article as it reads on the package site when this page was checked; it is the reference to consult when py_require() does not give you the environment you expect (the article says to check that nothing higher in the list is being found first, for instance the r-reticulate virtualenv, which virtualenv_remove("r-reticulate") deletes).

  1. The interpreter named by the RETICULATE_PYTHON environment variable.

  2. The virtual or conda environment named by RETICULATE_PYTHON_ENV.

  3. A mandatory request from use_python(), use_virtualenv() or use_condaenv() (required = TRUE, the default).

  4. If RETICULATE_USE_MANAGED_VENV="yes", the ephemeral environment that satisfies the py_require() manifest.

  5. The environment already activated through VIRTUAL_ENV (an activate script run before R started, or the RStudio option to activate project-local environments).

  6. A poetry project (pyproject.toml with a [tool.poetry] section) or a pipenv project (Pipfile) in the working directory.

  7. A directory named venv, virtualenv, .venv or .virtualenv in the working directory, when it is a virtual environment.

  8. The environment named in import("bar", delay_load = list(environment = "r-barlyr")) by a package, if it exists.

  9. A use_*() request with required = FALSE, or one made from a package’s .onLoad(), if that interpreter exists.

  10. A virtual or conda environment named r-bar, when the code calls import("bar").

  11. The interpreter named by RETICULATE_PYTHON_FALLBACK.

  12. A virtual environment named r-reticulate, if it exists.

  13. Otherwise the ephemeral environment resolved from the py_require() manifest, unless RETICULATE_USE_MANAGED_VENV="no".

  14. Last of all, the Python on the PATH (or in the Windows registry); when both python and python3 are on the PATH, python3 is preferred unless only python has NumPy installed or python3 was built for a different architecture than R.

Items 1 to 3 are decisions you make; items 5 to 7 are conventions that pick up the environment of the project you are in, and items 8 to 10 are what packages can arrange; items 11 to 14 are the fallbacks, and the managed environment (item 13) is the new default one. Notice that a virtualenv left behind by an earlier tutorial (r-reticulate, item 12) still outranks py_require().

Creating environments yourself

The functions that create environments predate py_require() and remain supported. They are fragments here, because they build environments and download packages, and the articles that describe them now carry a banner of updated guidance: managing installations by hand is generally unnecessary, and no longer recommended, since 1.41. The virtualenv functions create a venv from a "starter" interpreter (virtualenv_starter(all = TRUE) lists the candidates) and install into it with pip; py_install() is the one-line version, installing into the environment named r-reticulate; the conda functions do the same for conda, and install_miniconda() has installed miniforge rather than Miniconda since 1.44.0. install_python() builds a Python (3.12 by default since 1.44.1).

library(reticulate)

# A virtual environment, then the packages the shelf scripts need.
virtualenv_create("shelf")
virtualenv_install("shelf", c("numpy", "pandas"))
use_virtualenv("shelf", required = TRUE)

# The shortcut: pip-install into the default "r-reticulate" environment.
py_install(c("numpy", "pandas"))

# The conda route, with the miniforge that reticulate can install.
install_miniconda()
conda_create("shelf", packages = c("numpy", "pandas"))
use_condaenv("shelf", required = TRUE)

The same step in plain Python is python -m venv and pip install, covered by Virtual Environments and Packaging; reticulate’s functions are wrappers for those tools, and nothing stops you from creating the environment in a terminal and naming it with use_virtualenv() or RETICULATE_PYTHON. Inside repl_python() a line that starts with ! runs a shell command, so !pip install scipy installs into the interpreter reticulate is using (An interactive Python prompt).

Asking reticulate what it chose

py_config() returns a description of the interpreter that is in use and starts Python if it was not started yet; py_discover_config() returns the same description for the interpreter reticulate would choose, without loading it. py_available() is FALSE until Python has been initialised (unless initialize = TRUE), py_module_available("numpy") asks whether a module can be imported, and both are what a script or test uses to skip work when Python or a package is missing. The py_config object is a list with the fields python, libpython, pythonhome, version, numpy and others, among them forced, which names what forced the choice. Because the paths differ on every machine, the script prints only the version fields.

library(reticulate)

packageVersion("reticulate")
#> [1] ‘1.47.0’

# The interpreter is started lazily, the first time something needs it.
py_available()
#> [1] FALSE
py_module_available("numpy")
#> [1] TRUE
py_module_available("pandas")
#> [1] TRUE
py_module_available("no_such_module")
#> [1] FALSE

# py_config() describes the interpreter that was chosen, and why.
config <- py_config()
config$version
#> [1] ‘3.12’
config$numpy$version
#> [1] ‘2.5.4’
config$forced
#> [1] "RETICULATE_PYTHON"
py_available()
#> [1] TRUE

# The interpreter describes itself through its own sys module.
sys <- import("sys")
sys$version_info$major
#> [1] 3
sys$version_info$minor
#> [1] 12

The first py_available() is FALSE because nothing had started Python yet; the module checks and py_config() start it, and forced says the choice came from RETICULATE_PYTHON. Printing the whole object shows the layout below. The file paths and the interpreter’s build string were replaced with placeholders in this transcript, and the last line says why this interpreter was chosen, here that the Python version was forced by RETICULATE_PYTHON.

library(reticulate)
py_config()
#> python:         <path to python>
#> libpython:      <path to libpython3.12>
#> pythonhome:     <path to the Python prefix>
#> version:        3.12.3 (<build date and time>) [GCC 13.3.0]
#> numpy:          <path to numpy>
#> numpy_version:  2.5.4
#>
#> NOTE: Python version was forced by RETICULATE_PYTHON

When a module is missing

The error that users meet most is an import that fails because the package was installed into a different Python than the one reticulate bound to. It is an ordinary R error, with the Python exception’s class on the condition as well, so it can be caught like one. The message names the module; the instruction to run py_last_error() is part of the text, and py_last_error() returns the exception, with the Python traceback and the R call stack that led to it, as a list of class py_error.

library(reticulate)

# A Python ImportError becomes an R error that carries the Python exception.
e <- tryCatch(import("no_such_module"), error = \(e) e)
class(e)
#> [1] "python.builtin.ModuleNotFoundError" "python.builtin.ImportError"
#> [3] "python.builtin.Exception"           "python.builtin.BaseException"
#> [5] "python.builtin.object"              "error"
#> [7] "condition"
conditionMessage(e)
#> [1] "ModuleNotFoundError: No module named 'no_such_module'\nRun `reticulate::py_last_error()` for details."

# py_last_error() describes the most recent Python exception.
err <- py_last_error()
class(err)
#> [1] "py_error"
names(err)
#> [1] "type"      "value"     "traceback" "message"   "r_call"    "r_class"
#> [7] "r_trace"
err$type
#> [1] "ModuleNotFoundError"
err$value
#> [1] "No module named 'no_such_module'"

# The condition classes make Python-specific handlers possible.
tryCatch(
  import("no_such_module"),
  python.builtin.ImportError = \(e) "no such module, using the fallback"
)
#> [1] "no such module, using the fallback"

Notice the condition classes: the Python class names, prefixed with python.builtin., come first, then error and condition, so tryCatch(..., python.builtin.ImportError = ) catches exactly this failure and nothing else. That behaviour is from reticulate 1.29.0, whose NEWS says Python exceptions now inherit from error and condition and can be passed to stop(); the condition became an R list instead of an environment in 1.38.0, for compatibility with rlang and purrr. The full R call stack is in py_last_error()$r_trace$full_call, which is the thing to inspect when the Python error was raised deep inside a callback. Conditions and Error Handling explains the handlers used here.

Importing modules and calling Python

import("name") is Python’s import name, executed from R, and returns an object of class python.builtin.module. The dollar operator $ reaches everything inside it, whether that is a function, a class, a constant or a submodule, and it works the same way on any Python object: np$arange(10L)$reshape(2L, 5L) is np.arange(10).reshape(2, 5). Calling a Python function from R looks like calling an R function, with positional and named arguments; the named ones become Python keyword arguments (builtins$sorted(x, reverse = TRUE)).

The functions that start from a module other than a plain import are small: import_builtins() returns Python’s builtins module (len(), sorted(), open(), int()), import_main() returns __main__, the module where py_run_string() and py_run_file() define things, and import_from_path("mymodule", path = ".") imports a module from a directory that is not on Python’s path, which is how you load a module that ships inside an R package (system.file("python", package = "mypkg")). import() has an as argument that renames the module in the R class (aimed at package developers) and delay_load = TRUE, which postpones the import until first use so that a package can load without Python installed and let the user configure it first (Python in an R package). The help of a Python object is available as text with py_help(), its attributes with py_list_attributes() or names(), and tests for a single attribute with py_has_attr().

A few Python names are not valid R names: the double-underscore attributes need backticks around the name after $ (the script in Sourcing a Python file reads a docstring that way), or py_get_attr(x, "__doc__"), and so does a Python method that happens to have the name of an R keyword. The script below uses standard-library modules, so it runs anywhere.

library(reticulate)

# import() returns a module object; `$` reaches its functions and attributes.
math <- import("math")
class(math)
#> [1] "python.builtin.module" "python.builtin.object"
math$pi
#> [1] 3.141593
math$sqrt(2)
#> [1] 1.414214
math$floor(2.7)
#> [1] 2
class(math$floor(2.7))
#> [1] "integer"

# Python's built-in functions live in the builtins module.
builtins <- import_builtins()
builtins$len("shelf")
#> [1] 5
builtins$sorted(c("b", "a", "c"))
#> [1] "a" "b" "c"
builtins$max(c(3, 9, 4))
#> [1] 9

# convert = FALSE keeps results as Python objects until py_to_r() is called.
collections <- import("collections", convert = FALSE)
counts <- collections$Counter(c("Fiction", "Poetry", "Fiction"))
counts
#> Counter({'Fiction': 2, 'Poetry': 1})
class(counts)
#> [1] "collections.Counter"   "python.builtin.dict"   "python.builtin.object"
counts$most_common(1L)
#> [('Fiction', 2)]
py_to_r(counts$most_common(1L))
#> [[1]]
#> [[1]][[1]]
#> [1] "Fiction"
#>
#> [[1]][[2]]
#> [1] 2
#>
#>

# The module's attributes can be listed and tested.
"sqrt" %in% py_list_attributes(math)
#> [1] TRUE
py_has_attr(math, "cbrt")
#> [1] TRUE
py_has_attr(math, "no_such_function")
#> [1] FALSE
head(py_list_attributes(math), 4)
#> [1] "__doc__"     "__loader__"  "__name__"    "__package__"

Several things happened there. math$floor(2.7) returned a Python int, which became an R integer; builtins$max(c(3, 9, 4)) was handed a vector, converted to a Python list, and returned a float, which became an R double. The Counter was imported with convert = FALSE, so counts and counts$most_common(1L) stayed Python objects and printed with Python’s repr(); py_to_r() converted the list of tuples into an R list at the end. That is the pattern for any chain of Python calls whose intermediate results you do not want converted: import the module with convert = FALSE, chain the calls, and convert the final value. The $ operator on a module imported with convert = FALSE returns Python objects, and one imported with the default converts each result as it comes back.

The same machinery works on classes and instances: collections$Counter is a Python class, calling it creates an instance, and counts$most_common is a bound method. A Python object of a custom class is returned to R as a reference whose class vector starts with the module and class name (collections.Counter above) and ends with python.builtin.object; its methods and properties are used with $ as if it were an R reference class (Reference Classes and R6 describes those). Python’s operators work on such references through R’s group generics since 1.29.0 (for example x + y on two NumPy arrays), and [ and [<- call Python’s __getitem__ and __setitem__ since 1.31.0, with Python’s 0-based indexes (Numbers, indexes and big integers).

Type conversion

Every call from R to Python converts the arguments, and every result that comes back is converted, unless the object was created with convert = FALSE. The rules are the content of the "Type Conversions" section of the article Calling Python from R, and the two functions that apply them explicitly are r_to_py() and py_to_r(). This section reports what reticulate 1.47.0 actually did with each value on this page’s image, which differs from the article in one place (a list of mixed types).

From R to Python

R value Python object Notes

1, 1L, TRUE, "foo"

float, int, bool, str

A length-one vector is a scalar. A double is a Python float: write 1L for an int.

NULL

None

c(1, 2, 3), 1:3, c("a", "b")

list

A longer atomic vector is a list. Names are dropped. character(0) is an empty list.

list(1L, TRUE, "foo")

list

An unnamed list is a list, whatever the element types. The article lists a tuple here; use tuple() when Python needs one.

list(a = 1L, b = 2)

dict

A named list is a dictionary; dict() and py_dict() build one explicitly.

matrix(1:4, 2), array(...)

numpy.ndarray

The array shares R’s memory, and keeps R’s column-major layout (R and NumPy arrays). Integers are int32, doubles float64.

data.frame(...)

pandas.DataFrame

Column by column; see R and pandas data frames.

as.raw(1:3)

bytearray

as.Date("2026-06-30")

datetime.date

Date vectors in a data frame become a column of such objects; use POSIXct for datetime64.

factor(c("a", "b"))

list of strings

In a data frame a factor becomes a pandas category.

function(x) x + 1

a Python function

With a generic (*args, **kwargs) signature; py_func() wraps an R function with its real signature.

any other R object (an environment, a symbol)

an opaque PyCapsule

Python can hold it and hand it back, but cannot look inside.

The script below computes the type and repr() of the Python object created from each of these values, so that nothing in the table is taken on trust. The repr() of a function and of a capsule contain a memory address, which the script removes with sub().

library(reticulate)

# For each R value: the type of the Python object it becomes, and its repr().
builtins <- import_builtins(convert = FALSE)
show_py <- function(label, x) {
  p <- r_to_py(x)
  type <- py_to_r(builtins$type(p)$`__name__`)
  text <- py_to_r(builtins$repr(p))
  text <- gsub("\\s+", " ", sub(" at 0x[0-9a-f]+", "", text))
  cat(sprintf("%-26s %-10s %s\n", label, type, text))
}

show_py("1", 1)
#> 1                          float      1.0
show_py("1L", 1L)
#> 1L                         int        1
show_py("TRUE", TRUE)
#> TRUE                       bool       True
show_py("\"foo\"", "foo")
#> "foo"                      str        'foo'
show_py("NULL", NULL)
#> NULL                       NoneType   None
show_py("c(1, 2, 3)", c(1, 2, 3))
#> c(1, 2, 3)                 list       [1.0, 2.0, 3.0]
show_py("1:3", 1:3)
#> 1:3                        list       [1, 2, 3]
show_py("c(\"a\", \"b\")", c("a", "b"))
#> c("a", "b")                list       ['a', 'b']
show_py("c(a = 1, b = 2)", c(a = 1, b = 2))
#> c(a = 1, b = 2)            list       [1.0, 2.0]
show_py("list(1L, TRUE, \"foo\")", list(1L, TRUE, "foo"))
#> list(1L, TRUE, "foo")      list       [1, True, 'foo']
show_py("list(a = 1L, b = 2)", list(a = 1L, b = 2))
#> list(a = 1L, b = 2)        dict       {'a': 1, 'b': 2.0}
show_py("list()", list())
#> list()                     list       []
show_py("character(0)", character(0))
#> character(0)               list       []
show_py("matrix(1:4, 2)", matrix(1:4, 2))
#> matrix(1:4, 2)             ndarray    array([[1, 3], [2, 4]], dtype=int32)
show_py("as.raw(1:3)", as.raw(1:3))
#> as.raw(1:3)                bytearray  bytearray(b'\x01\x02\x03')
show_py("as.Date(\"2026-06-30\")", as.Date("2026-06-30"))
#> as.Date("2026-06-30")      date       datetime.date(2026, 6, 30)
show_py("factor(c(\"a\", \"b\"))", factor(c("a", "b")))
#> factor(c("a", "b"))        list       ['a', 'b']
show_py("function(x) x + 1", function(x) x + 1)
#> function(x) x + 1          function   <function make_python_function.<locals>.python_function>
show_py("new.env()", new.env())
#> new.env()                  PyCapsule  <capsule object "r_object">
show_py("tuple(1L, 2L)", tuple(1L, 2L))
#> tuple(1L, 2L)              tuple      (1, 2)
show_py("dict(a = 1L)", dict(a = 1L))
#> dict(a = 1L)               dict       {'a': 1}

The row for list(1L, TRUE, "foo") is the one that differs from the article’s table, which says a list of multiple types becomes a tuple. In 1.47.0 it is a list. The rule for choosing is the same as in Python itself: ask for a tuple when an API requires one. The matrix keeps its shape, two rows and two columns, and its R values: only its memory order differs from NumPy’s default, and the repr() hides that (R and NumPy arrays).

From Python to R

Python value R value Notes

int, float, bool, str

integer, double, logical, character

A Python int outside the 32-bit range is a problem (Numbers, indexes and big integers).

None

NULL

Inside a list it is an element NULL.

a list of ints, floats, bools or strings

an atomic vector

When all the elements have the same simple type; [1, 2, 3] is 1:3.

a list of mixed types, a tuple

a list

Nested lists stay lists; a list of equal-length numeric lists is not turned into a matrix.

a dict

a named list

Non-string keys are converted to character names.

a numpy.ndarray

an R array (column-major copy)

A one-dimensional array is a one-dimensional R array.

a pandas.DataFrame

a data.frame

The index is kept in the pandas.index attribute; a string index is also used as the row names.

a set, bytes, range, a generator, a function

a Python object reference

Left as references: builtins$list(x) makes a list that converts (script 07 does it for a range), and iterate() consumes a generator.

a Python object of a custom class

a reference with class python.builtin.object

Use $ on it.

library(reticulate)

# For each Python expression: the R class and value it converts to.
show_r <- function(code) {
  x <- py_eval(code)
  cat(sprintf("%-26s %-22s %s\n", code, class(x)[[1L]], deparse1(x)))
}

show_r("1")
#> 1                          integer                1L
show_r("1.5")
#> 1.5                        numeric                1.5
show_r("True")
#> True                       logical                TRUE
show_r("'text'")
#> 'text'                     character              "text"
show_r("None")
#> None                       NULL                   NULL
show_r("float('nan')")
#> float('nan')               numeric                NaN
show_r("[1, 2, 3]")
#> [1, 2, 3]                  integer                1:3
show_r("[1.5, 2.5]")
#> [1.5, 2.5]                 numeric                c(1.5, 2.5)
show_r("['a', 'b']")
#> ['a', 'b']                 character              c("a", "b")
show_r("[1, 'a', True]")
#> [1, 'a', True]             list                   list(1L, "a", TRUE)
show_r("[[1, 2], [3, 4]]")
#> [[1, 2], [3, 4]]           list                   list(1:2, 3:4)
show_r("[None, 1]")
#> [None, 1]                  list                   list(NULL, 1L)
show_r("[]")
#> []                         list                   list()
show_r("(1, 2)")
#> (1, 2)                     list                   list(1L, 2L)
show_r("{'a': 1, 'b': 2.5}")
#> {'a': 1, 'b': 2.5}         list                   list(a = 1L, b = 2.5)
show_r("{1: 'a'}")
#> {1: 'a'}                   list                   list("1" = "a")
show_r("complex(1, 2)")
#> complex(1, 2)              complex                1+2i
show_r("{1, 2, 3}")
#> {1, 2, 3}                  python.builtin.set     <environment>
show_r("b'ab'")
#> b'ab'                      python.builtin.bytes   <environment>
show_r("range(3)")
#> range(3)                   python.builtin.range   <environment>
show_r("(n for n in range(3))")
#> (n for n in range(3))      python.builtin.generator <environment>

Two details in that output. A Python float('nan') arrives as NaN, an R double that is.na() reports as TRUE, which is the closest R has, and None inside a list arrives as an element NULL, which is not the same as NA and disappears from unlist(). And a set, bytes, range or generator object is returned as a reference of class python.builtin.set, python.builtin.bytes and so on; py_len(), iterate() and as.raw() work on them, and py_to_r() returns them unchanged (the NEWS entry of 1.36.0 says py_to_r(x) returns x unmodified when it is not a Python object).

Explicit control: tuple(), dict(), py_dict(), np_array()

The automatic rules are right most of the time, and the exceptions are fixed by asking for the Python type. tuple() makes a tuple (its argument can be the elements or one list), dict() makes a dictionary from named arguments or from one named list, py_dict() takes a vector of keys and a vector of values (so keys can be Python objects such as tuples) and np_array() creates a NumPy array with a chosen dtype and memory order. The function list() is how you pass a single element as a Python list rather than a scalar: foo$bar(indexes = list(42L)), from the article.

library(reticulate)

# A multi-element vector is a Python list; tuple() makes a tuple.
tuple("a", "b", "c")
#> ('a', 'b', 'c')
tuple(list("x", "y"))
#> ('x', 'y')
class(tuple(1L, 2L))[1]
#> [1] "python.builtin.tuple"

# A named list is a dict; dict() builds one, with non-string keys if needed.
d <- dict(genre = "Poetry", pages = 96L)
d
#> {'genre': 'Poetry', 'pages': 96}
class(d)[1]
#> [1] "python.builtin.dict"
names(d)
#> [1] "genre" "pages"
d$pages
#> 96
py_to_r(d)
#> $genre
#> [1] "Poetry"
#>
#> $pages
#> [1] 96
#>

# py_dict() takes keys and values separately, so keys can be any value.
py_dict(list(1L, 2L), list("one", "two"))
#> {1: 'one', 2: 'two'}

# dict() looks each name up in the calling frame first: a variable that holds
# a Python object is used as the key instead of its name.
key <- tuple("fiction", 2024L)
dict(key = "count")
#> {('fiction', 2024): 'count'}
rm(key)
dict(key = "count")
#> {'key': 'count'}

The last two calls show a rule of dict() that is easy to trip over: before using the argument name as a string key, it looks the name up in the calling environment, and if a variable of that name holds a Python object, that object is the key. With key holding a tuple, dict(key = "count") made a dictionary keyed by the tuple; after rm(key) it made the string key 'key'. The reference page says that dict() can create a dictionary whose keys are other Python objects, and the lookup by variable name is how the source of dict() finds them; it means that dict(b = 1) is not safe in a script in which b is a Python object. py_dict() has no such lookup.

Numbers, indexes and big integers

R and Python disagree about the default numeric type: 42 is a double in R and an int in Python. When a Python function wants an integer it says so with a TypeError, and the fix is the L suffix (or as.integer()). Python collections count from 0, and a method that takes an index takes a 0-based one, passed as an integer. The script shows the errors and then the sharpest edge in the conversion: how a Python int that does not fit in 32 bits is converted.

library(reticulate)
math <- import("math")
builtins <- import_builtins()

# 5 is a double in R and a float in Python; 5L is an integer.
math$factorial(5L)
#> [1] 120
tryCatch(math$factorial(5), error = \(e) conditionMessage(e))
#> [1] "TypeError: 'float' object cannot be interpreted as an integer\nRun `reticulate::py_last_error()` for details."
tryCatch(builtins$range(3), error = \(e) conditionMessage(e))
#> [1] "TypeError: 'float' object cannot be interpreted as an integer\nRun `reticulate::py_last_error()` for details."
builtins$list(builtins$range(3L))
#> [1] 0 1 2

# Python counts from 0: the position is an integer index, not an R index.
titles <- r_to_py(list("Loud Roofs", "Paper Moons", "Ten Winters"))
titles[0L]
#> 'Loud Roofs'
titles[-1L]
#> 'Ten Winters'
py_len(titles)
#> [1] 3

# A Python int outside the 32-bit range is converted by truncation.
py_eval("2**31 - 1")
#> [1] 2147483647
py_eval("2**31")
#> [1] NA
py_eval("2**40")
#> [1] 0

# Keep such values as Python objects, or convert them through float or str.
big <- py_eval("2**40", convert = FALSE)
big
#> 1099511627776
as.character(big)
#> [1] "1099511627776"
py_to_r(builtins$float(big))
#> [1] 1.099512e+12
format(py_to_r(builtins$float(big)), scientific = FALSE)
#> [1] "1099511627776"

# NumPy 64-bit integers convert to doubles.
np <- import("numpy", convert = FALSE)
py_to_r(np$int64(2^40))
#> [1] 1.099512e+12
class(py_to_r(np$int64(2^40)))
#> [1] "numeric"

# The integer64 class of the bit64 package is not understood: its bits are
# read as a double. Send it as text and let Python build the int.
id <- bit64::as.integer64("1099511627776")
r_to_py(id)
#> 5.43230922487e-312
py_builtins <- import_builtins(convert = FALSE)
py_builtins$int(as.character(id))
#> 1099511627776

R has only 32-bit integers, and reticulate converts a Python int to an R integer. In the 1.47.0 run above, values inside the 32-bit range arrive intact (2**31 - 1 is 2147483647), but 2**31 arrived as NA and 2**40 as 0: the values behave as if they were truncated to 32 bits, with no warning. This is behaviour to design around rather than a rule to quote: do not let a large Python integer through the automatic conversion. Keep it a Python object (convert = FALSE) and convert it explicitly through a float (exact up to 2^53) or a string, as above. NumPy’s int64 scalars and arrays are not affected; they become doubles, which is exact up to 2^53 as well. reticulate has no support for the integer64 class of the bit64 package: bit64 is not among the imports of reticulate, and a bit64 vector is sent to Python as a double whose bits are the integer’s bits, which is why r_to_py(id) printed a value close to zero. Identifiers that must keep every digit (ISBNs, loan identifiers) belong in character vectors on both sides.

Index conventions also differ for the Python objects you hold. titles[0L] is the first element and titles[-1L] the last; py_len() is Python’s len(). Mixing R’s 1-based positions with Python’s 0-based ones is the classic off-by-one when calling Python from R: the subsetting operators of R vectors keep R’s rules, the [ method of a Python object follows Python’s.

Missing values

R has an NA in every atomic type; Python has None and, for floats, nan; NumPy and pandas add their own sentinels. reticulate converts what it can, and for the rest the result is a number, not a missing value. The script prints the Python value that each R NA becomes, then the same for the columns of a small data frame.

library(reticulate)

# R's NA is a different value in each atomic type.
builtins <- import_builtins(convert = FALSE)
show_py <- function(label, x) {
  p <- r_to_py(x)
  type <- py_to_r(builtins$type(p)$`__name__`)
  cat(sprintf("%-26s %-8s %s\n", label, type, py_to_r(builtins$repr(p))))
}
show_py("NA", NA)
#> NA                         bool     True
show_py("NA_integer_", NA_integer_)
#> NA_integer_                int      -2147483648
show_py("NA_real_", NA_real_)
#> NA_real_                   float    nan
show_py("NA_character_", NA_character_)
#> NA_character_              str      'NA'
show_py("c(1.5, NA)", c(1.5, NA))
#> c(1.5, NA)                 list     [1.5, nan]
show_py("c(\"a\", NA)", c("a", NA))
#> c("a", NA)                 list     ['a', 'NA']

# Going the other way, None becomes NULL and NaN stays NaN.
py_eval("None")
#> NULL
py_eval("float('nan')")
#> [1] NaN
x <- py_eval("[1.5, float('nan'), 2.5]")
x
#> [1] 1.5 NaN 2.5
is.na(x)
#> [1] FALSE  TRUE FALSE
mean(x, na.rm = TRUE)
#> [1] 2

# Inside a data frame the same values are converted column by column.
df <- data.frame(n = c(1.5, NA), i = c(1L, NA), s = c("a", NA), l = c(TRUE, NA))
df_py <- r_to_py(df)
df_py
#>      n           i    s      l
#> 0  1.5           1    a   True
#> 1  NaN -2147483648  NaN  False
df_py$dtypes
#> n    float64
#> i      int32
#> s        str
#> l       bool
#> dtype: object
str(py_to_r(df_py))
#> 'data.frame':	2 obs. of  4 variables:
#>  $ n: num  1.5 NA
#>  $ i: int  1 NA
#>  $ s: chr  "a" NA
#>  $ l: logi  TRUE FALSE
#>  - attr(*, "pandas.index")=RangeIndex(start=0, stop=2, step=1)

Read this output carefully. A double NA becomes nan and comes back as NaN, which is.na() and mean(x, na.rm = TRUE) both handle. A scalar integer NA becomes the smallest 32-bit integer, -2147483648, which is how R itself stores it, and an R integer vector in a data frame keeps that value in an int32 column (it converts back to NA on the return trip, but pandas computes with the number). A scalar logical NA becomes True, and a logical NA in a data frame column becomes False, so the missing value is lost for good. A character NA becomes the string 'NA' when it is a scalar or an element of a vector, but in a data frame column it is a real missing value (displayed as NaN) that returns as NA. The remedies are in R and pandas data frames: convert logical and integer columns carefully, use the nullable dtypes for integers, and treat a logical NA as a case to handle before the data leave R.

Running Python code

py_run_string(code) executes a string in the __main__ module and py_run_file(file) a file, so the names they define are visible to later calls and to R through py, the object that reticulate exports for the main module (py$loan_days is __main__.loan_days; py$late_fee <- 0.25 sets a Python variable). py_eval(expr) evaluates one expression and returns its value. Both functions take convert and local: with local = TRUE the code runs in a private dictionary that is returned instead of the main module. py_run_string() and py_run_file() return the module’s dictionary invisibly, which is why the script ignores their value. Since 1.29.0 py_run_file() and source_python() put the script’s directory first in Python’s module search path while the script runs, as python script.py does at a terminal.

library(reticulate)

# py_run_string() runs code in the __main__ module; `py` is that module.
py_run_string("loan_days = 21")
py$loan_days
#> [1] 21
py$late_fee <- 0.25
py_eval("loan_days * late_fee")
#> [1] 5.25

# Functions defined in Python are callable from R through `py`.
py_run_string("
def overdue(days_out, limit=21):
    return days_out > limit
")
py$overdue(30L)
#> [1] TRUE
py$overdue(30L, limit = 45L)
#> [1] FALSE

# py_eval() evaluates one expression and returns its value.
py_eval("sorted({'b': 2, 'a': 1})")
#> [1] "a" "b"

# py_run_string() returns the module dictionary invisibly.
state <- py_run_string("x = 1", local = TRUE)
class(state)
#> [1] "python.builtin.dict"   "python.builtin.object"
names(state)
#> [1] "x"

# Output printed by Python can be captured as an R character vector.
captured <- py_capture_output(py_run_string("print('from python')"))
captured
#> [1] "from python\n"

# A Python exception is an R error; the message carries the exception type.
e <- tryCatch(py_run_string("1 / 0"), error = \(e) e)
conditionMessage(e)
#> [1] "ZeroDivisionError: division by zero\nRun `reticulate::py_last_error()` for details."
class(e)[1:2]
#> [1] "python.builtin.ZeroDivisionError" "python.builtin.ArithmeticError"
err <- py_last_error()
err$type
#> [1] "ZeroDivisionError"
err$value
#> [1] "division by zero"
cat(err$message)
#> Traceback (most recent call last):
#>   File "<string>", line 1, in <module>
#> ZeroDivisionError: division by zero

# Syntax errors surface the same way.
tryCatch(py_run_string("def broken("), error = \(e) conditionMessage(e))
#> [1] "  File \"<string>\", line 1\n    def broken(\n              ^\nSyntaxError: '(' was never closed\nRun `reticulate::py_last_error()` for details."

The part after the py_capture_output() call is the error path. py_run_string("1 / 0") raised a Python ZeroDivisionError, which reticulate turned into an R error whose message starts with the exception class, whose condition classes start with python.builtin.ZeroDivisionError, and whose last line is the pointer to py_last_error(). That function returns the most recent Python exception as a list of class py_error with type, value, traceback, message, r_call, r_class and r_trace (R’s call stack, summarised); printing it is the same as cat(err$message), the Python traceback exactly as the interpreter prints it. A syntax error in the string is reported the same way, with the offending line and a caret.

py_capture_output() evaluates an expression and returns what Python printed as a character vector, which is how to keep Python’s output out of an R report (py_suppress_warnings() does the same for Python warnings). What Python prints is shown in the R console, and since 1.31.0 reticulate flushes Python’s output buffers periodically, so the output of a long-running Python call appears while the call is still running.

Sourcing a Python file

A file of Python functions is the usual way to reuse Python code from R. source_python(file) runs the file in the main module, as py_run_file() does, and then copies every public object that the file created into an R environment, so Python functions can be called as R functions. The default environment is the calling one, which is convenient in a script and a trap in a larger one: the names land next to your R objects and silently replace any that have the same name. The remedy is the envir argument, which takes an environment of your own (or NULL, which assigns nothing), as the script does. The Python file is the shelf scenario’s ISBN-13 check, written in Python and shown here byte for byte. normalise_isbn() and is_valid_isbn13() do what the functions of the same names in scenario.R do; check_digit() computes the last digit from the first twelve.

"""ISBN-13 helpers for the shelf scenario, mirroring scenario.R."""

import re


def normalise_isbn(text):
    """Drop everything except digits and X, and upper-case the result."""
    return re.sub(r"[^0-9X]", "", text.upper())


def is_valid_isbn13(text):
    """Return True when text is a well-formed ISBN-13 with a valid check digit."""
    digits = normalise_isbn(text)
    if len(digits) != 13 or "X" in digits:
        return False
    weights = [1, 3] * 6 + [1]
    return sum(int(d) * w for d, w in zip(digits, weights)) % 10 == 0


def check_digit(first12):
    """Return the ISBN-13 check digit for the first twelve digits."""
    weights = [1, 3] * 6
    total = sum(int(d) * w for d, w in zip(first12, weights))
    return (10 - total % 10) % 10

The R script below runs from the directory that holds isbn.py and books.csv.

library(reticulate)
books <- read.csv("books.csv")

# source_python() runs the file in the main module, then copies its public
# objects into an R environment; a fresh environment keeps the names apart.
isbn <- new.env()
source_python("isbn.py", envir = isbn)
sort(ls(isbn))
#> [1] "check_digit"     "is_valid_isbn13" "normalise_isbn"
isbn$normalise_isbn("978-0-14-143951-8")
#> [1] "9780141439518"
isbn$is_valid_isbn13("978-0-14-143951-8")
#> [1] TRUE
isbn$check_digit("978014143951")
#> [1] 8

# The Python functions take one string, so apply them one element at a time.
valid <- vapply(books$isbn, isbn$is_valid_isbn13, logical(1), USE.NAMES = FALSE)
sum(valid)
#> [1] 14
books$title[!valid]
#> [1] "Rivers of Glass" "Orchard Logic"

# A vector argument arrives in Python as a list, and the function fails.
tryCatch(
  isbn$normalise_isbn(books$isbn[1:2]),
  error = \(e) conditionMessage(e)
)
#> [1] "AttributeError: 'list' object has no attribute 'upper'\nRun `reticulate::py_last_error()` for details."

# import_from_path() treats the file as a module instead of sourcing it.
isbn_module <- import_from_path("isbn", path = ".")
isbn_module$check_digit("978030640615")
#> [1] 7
isbn_module$normalise_isbn$`__doc__`
#> [1] "Drop everything except digits and X, and upper-case the result."
py_help(isbn_module$check_digit)
#> Help on function check_digit in module isbn:
#>
#> check_digit(first12)
#>     Return the ISBN-13 check digit for the first twelve digits.
#>

# py_run_file() runs the file and leaves its names in the main module.
invisible(py_run_file("isbn.py"))
py$check_digit("978030640615")
#> [1] 7

The count of 14 valid ISBNs out of 16 is the one that is_valid_isbn13() in scenario.R gives, and the two rejected titles are the row with the empty ISBN and the one with the wrong check digit. The scalar Python functions are applied with vapply(), one element at a time; handing the whole column to Python sends it as a list, and the first line of the Python function fails with the AttributeError printed above, a typical reticulate error: the R vector became a Python list, and list has no upper(). The fix is on the Python side (loop or comprehension over the list) or on the R side (vapply()). import_from_path() gives the same functions as attributes of a module object, with their docstrings (read here with backticks around __doc__), and py_help() prints Python’s own help text for an object; that text is printed by Python, not returned to R, and it is included in the transcript above. import_from_path() is the form to prefer for code that lives in a package, because it does not touch the main module. py_run_file() and source_python() execute the file in the main module, so any name it defines stays there (py$check_digit), shared with every other piece of Python code in the session.

An interactive Python prompt

repl_python() starts a Python REPL inside the R session. The code runs in the main module, the objects persist after exit, and R sees them through py. A line that starts with ! runs a shell command and %pip, %conda, %cd and a few other magics are supported; the reference page lists them. RStudio and Positron integrate it with their panes (the NEWS of 1.40.0 added Positron support for the Variables pane and for repl_python()). It is an interactive tool, so the block below is a fragment that was not run: the lines starting with >>> are typed at the Python prompt that repl_python() shows.

library(reticulate)

repl_python()
# >>> loan_days = 21
# >>> overdue = lambda days: days > loan_days
# >>> exit

py$loan_days
py$overdue(30L)

Callbacks, iterators and context managers

An R function can be given to Python wherever Python wants a callable: it is converted to a Python function with the generic signature (*args, **kwargs), so sorted(key = ), map(), functools.reduce() and many callbacks of scientific libraries work. A library that inspects the callback’s signature needs py_func(f), which wraps an R function so that Python sees the same argument names and defaults (R argument lists that Python cannot express, such as function(a = 1, b), are rejected). Python may call the R function from a background thread; since 1.39.0 that is safe, and py_main_thread_func() is deprecated.

Python iterators and generators are consumed with iterate(), which applies an R function to every item or, without one, collects them into a vector; an iterator can be drained only once. iter_next() takes one item at a time (returning NULL at the end, or the completed value you pass), and py_iterator() turns an R function into a Python iterator, where returning NULL ends the iteration (completed = changes the sentinel). with() runs R code inside a Python context manager, using %as% to name the object (with(open(path) %as% fh, ...) in the article’s form), and since 1.44.0 an error inside the block is forwarded to the context manager’s exit handler, so a database transaction rolls back.

library(reticulate)
source("scenario.R")
builtins <- import_builtins()

# An R function passed to Python becomes a Python callable.
by_length <- \(title) nchar(trimws(title))
builtins$sorted(trimws(books$title)[1:5], key = by_length)
#> [1] "Loud Roofs"          "Midnight Ledger"     "The Dispossessed"
#> [4] "the quiet streets"   "Pride and Prejudice"

# Python generators are consumed with iterate(), which can apply an R function.
squares <- py_eval("(n * n for n in range(5))")
total <- 0
iterate(squares, \(n) total <<- total + n)
total
#> [1] 30

# Without a function, iterate() collects the items into an R vector.
words <- py_eval("iter(['loan', 'return', 'renew'])")
collected <- iterate(words)
collected
#> [1] "loan"   "return" "renew"

# An R closure can be turned into a Python iterator; NULL ends the iteration.
countdown <- local({
  n <- 3L
  function() {
    if (n == 0L) {
      return(NULL)
    }
    n <<- n - 1L
    n + 1L
  }
})
iterate(py_iterator(countdown)) |> print()
#> [1] 3 2 1

# with() runs R code inside a Python context manager and closes the file.
path <- tempfile(fileext = ".txt")
written <- with(builtins$open(path, "w") %as% handle, {
  handle$write("shelf\n")
})
written
#> [1] 6
readLines(path)
#> [1] "shelf"
unlink(path)

iterate() returns its value invisibly at the top level, which is why the script prints the collected values through print() or an assignment; the closure in countdown is the R idiom for state that Python’s yield keeps for you (see Scoping, Environments and Closures for how a closure keeps state, and Iterators, Generators, and Comprehensions for the Python side).

R and pandas data frames

An R data.frame is converted to a pandas DataFrame column by column, and back. pandas is the Python library for tabular data, the counterpart of the data frame of Data Frames and the data.table of data.table, and being able to hand a table to it (or to receive one from a Python library that returns one) is a common reason to use reticulate. Each column follows the array rules: the numeric, logical and character vectors become NumPy arrays or pandas string columns, and the article names two extensions to them: a factor becomes a pandas categorical variable, and a POSIXct column becomes a datetime64[ns] column. If the R data frame has row names, they become the pandas index, and the other way round; since 1.47.0 explicit integer row names are preserved as the index too (the NEWS entry for r_to_py).

What pandas 3.0 changed

The Python side has moved under reticulate’s feet, and the changes are the reason several examples below differ from older tutorials. pandas 3.0.0 was released on 21 January 2026 and the page here uses 3.0.6. Its release notes list three changes that matter at the boundary. First, a dedicated string dtype is the default: columns of strings are inferred as the new str dtype (backed by PyArrow when it is installed and by NumPy objects otherwise, which is the case here), where earlier versions used the NumPy object dtype; the missing-value marker of the str dtype is always NaN. Second, copy-on-write is the only behaviour: the result of any indexing operation or method behaves as a copy, a chained assignment such as df[mask]["x"] = 0 no longer works, and the SettingWithCopyWarning is gone. Third, when strings or datetime objects are converted to datetime64, pandas infers the resolution instead of always using nanoseconds, and the default for parsing strings is microseconds; the minimum supported Python is 3.11. reticulate gained pandas 3.0 support in 1.45.0 (2026-02-13), and 1.47.0 converts pandas frames and series with Arrow-backed string columns. The 1.45.0 NEWS is the first to announce pandas 3.0 support; earlier releases were not checked against pandas 3 for this page.

Sending a data frame to pandas

The script cleans the genre column the way Factors does, selects five columns of books, converts the data frame and inspects what pandas received. The shape and dtypes come back as Python objects, because r_to_py() returns an object that does not convert on access; call py_to_r() on one when you need R.

source("scenario.R")
library(reticulate)

# Tidy the genre first, then send a data frame to pandas.
shelf <- books[, c("title", "isbn", "year", "genre", "pages")]
shelf$genre <- factor(tolower(trimws(shelf$genre)))
shelf_py <- r_to_py(shelf)
class(shelf_py)[1]
#> [1] "pandas.DataFrame"
shelf_py$shape
#> (16, 5)
shelf_py$dtypes
#> title         str
#> isbn          str
#> year        int32
#> genre    category
#> pages       int32
#> dtype: object
shelf_py$head(3L)
#>                   title               isbn  year            genre  pages
#> 0      The Dispossessed  978-0-306-40615-7  1974  science fiction    387
#> 1   Pride and Prejudice  978-0-14-143951-8  1813          classic    432
#> 2    the quiet streets       9780001000100  2019          fiction    214

# A factor becomes a pandas category; its levels become the categories.
shelf_py$genre$cat$categories
#> Index(['classic', 'fiction', 'mystery', 'non-fiction', 'poetry',
#>        'science fiction'],
#>       dtype='str')

# R integer NA has no NumPy equivalent: it arrives as the smallest int32.
shelf_py$year$isna()$sum()
#> np.int64(0)
shelf_py$year$min()
#> np.int32(-2147483648)
shelf_py$pages$mean()
#> np.float64(-268435221.3125)

# The nullable dtypes keep NA as pandas' own missing value.
options(reticulate.pandas_use_nullable_dtypes = TRUE)
shelf_nullable <- r_to_py(shelf)
options(reticulate.pandas_use_nullable_dtypes = FALSE)
shelf_nullable$dtypes
#> title      string
#> isbn       string
#> year        Int32
#> genre    category
#> pages       Int32
#> dtype: object
shelf_nullable$year$isna()$sum()
#> np.int64(1)
shelf_nullable$pages$mean()
#> np.float64(268.2142857142857)
shelf_nullable$head(8L)
#>                    title               isbn  year            genre  pages
#> 0       The Dispossessed  978-0-306-40615-7  1974  science fiction    387
#> 1    Pride and Prejudice  978-0-14-143951-8  1813          classic    432
#> 2     the quiet streets       9780001000100  2019          fiction    214
#> 3             Loud Roofs  978-0-00-200029-1  2021          fiction    198
#> 4        Midnight Ledger  978-0-00-300031-3  2016          mystery    301
#> 5          A Map of Salt      9780004000404  2008      non-fiction   <NA>
#> 6  Garden of Small Fires  978-0-00-500057-1  <NA>           poetry     96
#> 7       The Lamp at Noon  978-0-00-600062-4  1999          classic    280

The first half is the happy path: strings arrive as str, the factor as category with its levels as categories, integers as int32. The second half is the NA trap of Type conversion. shelf_py$year$isna()$sum() reports 0 missing values although books has one: the R integer NA is the smallest 32-bit integer, and shelf_py$year$min() shows it as -2147483648. Any statistic computed in pandas treats it as a year, and pages$mean() came out negative. pandas has a proper answer, the nullable dtypes (Int32, string, boolean), whose missing value is pd.NA. reticulate turns them on with an R option, reticulate.pandas_use_nullable_dtypes, set to TRUE while converting (NEWS of 1.32.0: reticulate can cast R data frames to pandas data frames with nullable dtypes, opt-in); with it the year column is Int32 with one missing value, the mean of pages ignores the two missing values, and the missing values print as <NA>.

The option name needs care. The code chunk of the package’s "Calling Python from R" article sets reticulate.pandas_use_nullable_data_types, while its text and the NEWS entry both say reticulate.pandas_use_nullable_dtypes; in the run behind this page only the dtypes spelling changed anything (a column converted under the other spelling was still plain int32). Use the dtypes spelling and check the dtypes you get, as the script does.

Getting a data frame back

py_to_r() converts a pandas DataFrame to an R data.frame, a category column to a factor and a datetime64 column to POSIXct, and the nullable dtypes to integer, logical or character vectors with NA. The index is not a column: it is stored in the pandas.index attribute of the result, and a string index also becomes the row names. This matters after groupby(), whose group labels are the index, and that is why the script resets the index before converting. pandas does the work here; R only receives the table.

source("scenario.R")
library(reticulate)

shelf <- books[, c("title", "isbn", "year", "genre", "pages")]
shelf$genre <- factor(tolower(trimws(shelf$genre)))
options(reticulate.pandas_use_nullable_dtypes = TRUE)
shelf_py <- r_to_py(shelf)

# pandas does the grouping; the group labels are its index, not a column.
by_genre <- shelf_py$groupby("genre", observed = TRUE)$agg(
  dict(pages = "mean", year = "max")
)
by_genre
#>                       pages  year
#> genre
#> classic               356.0  1999
#> fiction          222.666667  2023
#> mystery               272.5  2018
#> non-fiction           263.5  2020
#> poetry                 92.0  2005
#> science fiction       373.0  2017

# py_to_r() keeps only the columns: the index becomes an attribute.
names(py_to_r(by_genre))
#> [1] "pages" "year"
rownames(py_to_r(by_genre))
#> [1] "1" "2" "3" "4" "5" "6"

# reset_index() turns the index back into a column first.
by_genre_r <- py_to_r(by_genre$reset_index())
by_genre_r
#>             genre    pages year
#> 1         classic 356.0000 1999
#> 2         fiction 222.6667 2023
#> 3         mystery 272.5000 2018
#> 4     non-fiction 263.5000 2020
#> 5          poetry  92.0000 2005
#> 6 science fiction 373.0000 2017
sapply(by_genre_r, \(column) class(column)[[1L]])
#>     genre     pages      year
#>  "factor" "numeric" "integer"

# A round trip gives the columns back; only the pandas.index attribute is extra.
back <- py_to_r(shelf_py)
attr(back, "pandas.index")
#> RangeIndex(start=0, stop=16, step=1)
attr(back, "pandas.index") <- NULL
identical(back, shelf)
#> [1] TRUE

The converted by_genre has two value columns and no genre column: the genre labels were in the index, and names(py_to_r(by_genre)) shows only pages and year. After reset_index() the labels are a column again (a factor, because genre was a categorical). The round trip at the end gives back an identical data frame once the extra pandas.index attribute is removed; keep that attribute in mind when you compare, since identical() on the raw result is FALSE. The dict(pages = "mean", year = "max") is the Python dictionary that agg() takes, built from a named list (and, as the earlier warning says, it would use the Python object as the key if a variable named pages held one).

The same file read on either side

Python has its own CSV reader. Reading books.csv with pd$read_csv() returns a data frame converted on the way out, and the two readers disagree about empty fields: R’s read.csv() keeps an empty string in a character column and makes the numeric blanks NA; read_csv() reads every empty field as missing. The numbers also arrive as doubles when a column has missing values (NumPy has no integer NA), and the missing ones are NaN rather than NA. The arguments of read_csv() reproduce what R does, here with nullable integers for year and pages. The script runs in the directory that holds books.csv.

library(reticulate)
pd <- import("pandas")

# pandas reads the same file; the result is converted to an R data frame.
from_pandas <- pd$read_csv("books.csv")
from_r <- read.csv("books.csv")
class(from_pandas)
#> [1] "data.frame"
dim(from_pandas)
#> [1] 16  6
sapply(from_pandas, class)
#>       title      author        isbn        year       genre       pages
#> "character" "character" "character"   "numeric" "character"   "numeric"
sapply(from_r, class)
#>       title      author        isbn        year       genre       pages
#> "character" "character" "character"   "integer" "character"   "integer"

# Empty fields: R keeps "" in a character column, pandas reads them as missing.
sum(from_r$isbn == "")
#> [1] 1
sum(is.na(from_pandas$isbn))
#> [1] 1

# Explicit read_csv() arguments reproduce what read.csv() does.
explicit <- pd$read_csv(
  "books.csv",
  keep_default_na = FALSE,
  na_values = list(year = list(""), pages = list("")),
  dtype = list(year = "Int64", pages = "Int64")
)
sapply(explicit, class)
#>       title      author        isbn        year       genre       pages
#> "character" "character" "character"   "integer" "character"   "integer"
sum(explicit$isbn == "")
#> [1] 1
all.equal(explicit, from_r, check.attributes = FALSE)
#> [1] TRUE

For data that start in R, writing a file with R and reading it with pandas is also an option: exchanging a file that both languages read (CSV, for instance) is an alternative to the in-memory conversion. Which one is cheaper depends on the machine and the data, so measure with the tools of Performance, Profiling and Benchmarking before choosing.

Dates and times

Dates and times need the most care at the boundary. An R Date becomes a Python datetime.date, so a Date column is an object column of date objects, and it returns as a list column with one Date per element. An R POSIXct becomes datetime64[ns], the dtype pandas has for instants, and returns as POSIXct. (The NEWS of 1.36.0 says that a POSIXt vector with a non-empty tzone attribute converts to datetime.datetime values; in the data frame column below, which has the time zone "UTC", the dtype was datetime64[ns].) The practical rule is to convert Date columns to POSIXct before crossing, with a time zone, and to convert back with as.Date(). The missing values need the same care as the numbers: an NA in a Date column cannot be converted at all, and a missing POSIXct becomes NaT, pandas' missing time.

source("scenario.R")
library(reticulate)
pd <- import("pandas", convert = FALSE)

first <- loans[1:4, c("loan_id", "loan_date", "return_date")]

# A Date column becomes a pandas object column of datetime.date values ...
as_dates <- r_to_py(first)
as_dates$dtypes
#> loan_id           str
#> loan_date      object
#> return_date    object
#> dtype: object

# ... and comes back as a list column, which is rarely what you want.
class(py_to_r(as_dates)$loan_date)
#> [1] "list"

# A POSIXct column becomes datetime64[ns] and round-trips.
first$loan_date <- as.POSIXct(first$loan_date, tz = "UTC")
first$return_date <- as.POSIXct(first$return_date, tz = "UTC")
as_times <- r_to_py(first)
as_times$dtypes
#> loan_id                   str
#> loan_date      datetime64[ns]
#> return_date    datetime64[ns]
#> dtype: object
as_times
#>   loan_id  loan_date return_date
#> 1    L001 2025-01-14  2025-02-04
#> 2    L002 2025-01-16  2025-01-26
#> 3    L003 2025-01-21  2025-01-22
#> 4    L004 2025-01-26  2025-02-21
back <- py_to_r(as_times)
class(back$loan_date)
#> [1] "POSIXct" "POSIXt"
attr(back$loan_date, "tzone")
#> NULL
as.Date(back$loan_date)
#> [1] "2025-01-14" "2025-01-16" "2025-01-21" "2025-01-26"

# A missing Date cannot be converted, but a missing POSIXct becomes NaT.
missing_date <- loans[19:21, c("loan_id", "loan_date")]
tryCatch(r_to_py(missing_date), error = \(e) conditionMessage(e))
#> [1] "ValueError: year -2147483648 is out of range\nRun `reticulate::py_last_error()` for details."
missing_date$loan_date <- as.POSIXct(missing_date$loan_date, tz = "UTC")
r_to_py(missing_date)
#>    loan_id  loan_date
#> 19    L019 2025-07-02
#> 20    L020        NaT
#> 21    L021 2025-07-05

# Strings that are not dates become NaT too, when asked to.
pd$to_datetime(
  c("2025-02-30", "2025-01-14"),
  format = "%Y-%m-%d",
  errors = "coerce"
)
#> DatetimeIndex(['NaT', '2025-01-14'], dtype='datetime64[us]', freq=None)

The error in the middle is a real one: the missing date was stored as an integer sentinel, and the conversion to a datetime.date rejected it with "year -2147483648 is out of range". The time zone is not restored on the return trip (the tzone attribute is NULL), so how the values print depends on the time zone of the R session; set it explicitly when it matters. The last call is pandas' own parser, which also shows the pandas 3 resolution change: the dtype of the result is datetime64[us]. The loans data of the scenario has the impossible date 2025-02-30 for this reason, and errors = "coerce" is how pandas turns it into NaT where R’s as.Date() returns NA.

Copy-on-write: who owns the data

An R object is copied when it is modified (Assignment, Names and Values introduces copy-on-modify, and Memory and Copy Semantics goes deeper), and pandas 3.0 now behaves the same way for the user: a subset or a returned object never changes its parent. A data frame is converted column by column, and in the run below a change made on the Python side did not reach the R data frame; treat the two as separate objects. NumPy arrays are different, because a matrix shares its memory with R (R and NumPy arrays).

library(reticulate)
pd <- import("pandas")
pd$`__version__`
#> [1] "3.0.6"

# Since pandas 3.0 a column taken from a data frame behaves as a copy, so
# changing it never changes the data frame it came from.
py_run_string("
import pandas as pd
df = pd.DataFrame({'pages': [100, 200, 300]})
column = df['pages']
column.iloc[0] = 0
")
py$df
#>   pages
#> 1   100
#> 2   200
#> 3   300
py$column
#>   0   1   2
#>   0 200 300

# The R object that was sent over is a separate copy as well.
shelf_pages <- data.frame(pages = c(100L, 200L, 300L))
py$shelf_py <- r_to_py(shelf_pages)
py_run_string("shelf_py.iloc[0, 0] = 0")
shelf_pages
#>   pages
#> 1   100
#> 2   200
#> 3   300
py$shelf_py
#>   pages
#> 1     0
#> 2   200
#> 3   300

Before pandas 3.0, whether a derived object was a copy or a view depended on the operation performed, which the release notes call often confusing; under pandas 3 the column column is a copy as far as the user can tell, so py$df is unchanged while py$column shows the edit. The second half shows that a Python-side edit to a DataFrame built from an R data frame left the R data frame alone.

R and NumPy arrays

NumPy’s ndarray is the Python library’s dense array, and the conversion between it and an R matrix or array is the other half of the interface. The conversion rules are simple; the memory layout is what needs explaining, because R and NumPy order the elements of a matrix differently and reticulate does not hide that.

Column-major and row-major

A two-dimensional array is stored in memory as one long run of elements, and the layout decides which neighbours in the run are neighbours in the matrix. R, Fortran, MATLAB and the BLAS store a matrix column by column ("column-major" or Fortran order): the first index varies fastest. NumPy’s default is row by row ("row-major" or C order): the last index varies fastest. A 2 by 3 matrix whose elements are numbered in memory order from 1 to 6 therefore reads like this in the two layouts.

Memory order R (column-major) NumPy default (row-major)

1, 2, 3, 4, 5, 6

matrix(1:6, nrow = 2) is rows 1 3 5 and 2 4 6

np.arange(1, 7).reshape(2, 3) is rows 1 2 3 and 4 5 6

NumPy can use either layout and records which one an array has in its flags (C_CONTIGUOUS and F_CONTIGUOUS) and in its strides, the number of bytes to step to reach the next element along each axis. That generality is what lets reticulate hand an R matrix to NumPy without rearranging it: the array is a view of the same memory, marked Fortran-ordered. The Arrays in R and Python article states the three rules to remember: dense R arrays are presented to NumPy as column-major arrays; all NumPy arrays, whatever their layout, are presented to R as column-major arrays, because that is the only dense layout R has; and R and Python print n-dimensional arrays differently, with NumPy grouping by the first index, which makes a correct conversion look wrong.

library(reticulate)
np <- import("numpy", convert = FALSE)

# An R matrix is column-major; NumPy sees the same data without copying it.
m <- matrix(1:6, nrow = 2)
m
#>      [,1] [,2] [,3]
#> [1,]    1    3    5
#> [2,]    2    4    6
a <- r_to_py(m)
a
#> array([[1, 3, 5],
#>        [2, 4, 6]], dtype=int32)
a$shape
#> (2, 3)
a$flags
#>   C_CONTIGUOUS : False
#>   F_CONTIGUOUS : True
#>   OWNDATA : False
#>   WRITEABLE : False
#>   ALIGNED : True
#>   WRITEBACKIFCOPY : False
#>
a$strides
#> (4, 8)

# The array is a read-only view of R's memory, so Python cannot change it.
py$a <- a
tryCatch(py_run_string("a[0, 0] = 99"), error = \(e) conditionMessage(e))
#> [1] "ValueError: assignment destination is read-only\nRun `reticulate::py_last_error()` for details."
m[1, 1]
#> [1] 1

# np$array() makes a writable copy and keeps R's column-major layout.
copy <- np$array(m)
copy$flags$owndata
#> True
copy$flags$writeable
#> True
copy$flags$f_contiguous
#> True

# np_array() copies into the layout and type you ask for.
c_order <- np_array(m, order = "C")
c_order$flags$c_contiguous
#> True
c_order$flags$owndata
#> True
c_order$strides
#> (12, 4)
np_array(m, dtype = "float64")
#> array([[1., 3., 5.],
#>        [2., 4., 6.]])

# NumPy arrays always come back to R as a column-major copy.
rows <- np$arange(1L, 7L, dtype = "int32")$reshape(2L, 3L)
rows$flags$c_contiguous
#> True
back <- py_to_r(rows)
back
#>      [,1] [,2] [,3]
#> [1,]    1    2    3
#> [2,]    4    5    6
dim(back)
#> [1] 2 3

Read the flags. The array made from the R matrix is F_CONTIGUOUS, not C_CONTIGUOUS; OWNDATA is False, which says that it does not own its memory (it is a view of R’s), and WRITEABLE is False: in reticulate 1.47.0 with NumPy 2.5.4 Python cannot modify an R matrix through the view, and the attempt raises ValueError: assignment destination is read-only, which is protection for R’s copy semantics. The strides (4, 8) mean four bytes down a column (the next row of the same column) and eight bytes along a row (two integers of four bytes, the next column), the signature of column-major int32. The package article describes the same thing: R arrays are copied to Python only when they need to be, otherwise the data are shared, and Python arrays are always copied into R. Use np$array(m) for a writable copy that keeps R’s layout (its OWNDATA is True), and np_array(m, order = "C") for a row-major copy with the data type you choose (dtype = "float64" above): do that when the NumPy code that follows walks the array row by row, for which the C layout is faster. Neither changes any value or the matrix’s shape or the way it is indexed.

NumPy to R is always a copy, in column-major order. The matrix back has the same two rows and three columns and the same values as the NumPy array rows: the layout of the copy changed, not the array. That is the second rule at work: indexing back[1, 2] and rows[0, 1] reads the same element, and what differs is only the order in which each library would print or walk the data.

Reshaping and transposing

Reshaping is where the layouts bite. R’s dim<- reinterprets the vector column by column; NumPy’s reshape reads the elements in C order unless it is told otherwise. A script that reshapes in R and a script that reshapes in Python can therefore produce transposed results from the same vector. array_reshape() is the reticulate function that reshapes an R array with NumPy’s semantics: it fills the new dimensions in row-major order by default (order = "C"), or column-major with order = "F", in which case it agrees with dim<-. The base R tools to move between the layouts are matrix(byrow = TRUE) for two dimensions, t() and aperm() to permute the axes of a higher array.

library(reticulate)
np <- import("numpy", convert = FALSE)

# dim<- fills column by column; array_reshape() fills row by row, like NumPy.
v <- 1:6
array_reshape(v, c(2, 3))
#>      [,1] [,2] [,3]
#> [1,]    1    2    3
#> [2,]    4    5    6
array_reshape(v, c(2, 3), order = "F")
#>      [,1] [,2] [,3]
#> [1,]    1    3    5
#> [2,]    2    4    6
dim(v) <- c(2, 3)
v
#>      [,1] [,2] [,3]
#> [1,]    1    3    5
#> [2,]    2    4    6

# array_reshape() agrees with NumPy's default reshape (it returns doubles).
rows <- np$arange(1L, 7L, dtype = "int32")$reshape(2L, 3L)
all.equal(array_reshape(1:6, c(2, 3)), py_to_r(rows))
#> [1] TRUE

# Row-major data in R: byrow = TRUE for matrices, aperm() for higher arrays.
matrix(1:6, nrow = 2, byrow = TRUE)
#>      [,1] [,2] [,3]
#> [1,]    1    2    3
#> [2,]    4    5    6
cube_np <- np$arange(1L, 25L, dtype = "int32")$reshape(4L, 3L, 2L)
cube_r <- aperm(array(1:24, c(2, 3, 4)), c(3, 2, 1))
identical(py_to_r(cube_np), cube_r)
#> [1] TRUE

# The same array, indexed from 1 in R and from 0 in Python.
y <- array(1:24, c(4, 3, 2))
x <- np$array(y)
x$shape
#> (4, 3, 2)
x$flags$f_contiguous
#> True
identical(py_to_r(x[0L]), y[1, , ])
#> [1] TRUE
y[1, , ]
#>      [,1] [,2]
#> [1,]    1   13
#> [2,]    5   17
#> [3,]    9   21

# Python prints the first axis outermost, so the same data looks different.
x[0L]
#> array([[ 1, 13],
#>        [ 5, 17],
#>        [ 9, 21]], dtype=int32)

The first three results are the whole story: array_reshape(v, c(2, 3)) gives rows 1 2 3 and 4 5 6, the same as NumPy; array_reshape(v, c(2, 3), order = "F") and dim(v) <- c(2, 3) give the transposed fill. The comparison with NumPy is all.equal() and not identical() because array_reshape() returns doubles while the NumPy array was int32. The three-dimensional array y shows the printing rule: x[0L] in Python (the first slab along the first axis) has the same values as y[1, , ] in R, a 3 by 2 matrix, although the two outputs are printed differently. The instruction for the reverse (making an R array that a C-ordered NumPy expects) is the aperm(array(1:24, c(2, 3, 4)), c(3, 2, 1)) of the script, which reproduces NumPy’s arange(1, 25).reshape(4, 3, 2). The functions are described in the help pages of dim, aperm, matrix and array.

Calling NumPy on R vectors

Most of the time a NumPy call is a function on an R vector, and the conversion is invisible. The R vector becomes a list, NumPy builds an array from it, and the result is converted back. What is not invisible is that NumPy and R use different defaults for the same statistic. The script computes the median and the 90th percentile of the loan durations with NumPy and with R and shows the agreement once the defaults are aligned.

source("scenario.R")
library(reticulate)
np <- import("numpy")

# Loan durations in days for the loans that were returned and have valid dates.
returned <- loans[!is.na(loans$loan_date) & !is.na(loans$return_date), ]
days <- as.numeric(returned$return_date - returned$loan_date)
length(days)
#> [1] 52

# NumPy and R agree once the defaults are lined up: np.percentile interpolates
# linearly, like quantile(type = 7), and np.std divides by n unless ddof = 1.
np$percentile(days, c(50, 90))
#> [1] 21.0 28.9
quantile(days, c(0.5, 0.9), names = FALSE)
#> [1] 21.0 28.9
np$std(days)
#> [1] 9.007804
sd(days)
#> [1] 9.095687
np$std(days, ddof = 1L)
#> [1] 9.095687

# A vector goes in as a list and comes back as a one-dimensional R array.
np$cumsum(days[1:5])
#> [1] 21 31 32 58 84
class(np$cumsum(days[1:5]))
#> [1] "array"

np.percentile interpolates linearly by default, which is type = 7 of quantile() (R’s default), so the two agree; np.std divides by the number of observations, sd() by the number minus one, so ddof = 1L is needed for them to agree. This is a general pattern when moving a statistic between libraries: check the default of the same-named function on both sides, as Descriptive Statistics and Tests does for R’s own tests.

Python in an R package

A package that wraps a Python library has three jobs: declare the Python dependency, avoid starting Python when the package loads, and find the user’s environment. reticulate’s article on use in an R package gives the current recipe. Since 1.41 the package calls py_require() from .onLoad() to declare its Python packages, which combine with the user’s and other packages' declarations and have no effect on users who manage their own Python. The module is imported with delay_load = TRUE, so that loading the R package neither fails when Python is missing (important for CRAN checks) nor locks in an interpreter before the user has chosen one. Python code that ships with the package lives in inst/python and is imported with import_from_path(). The package should avoid use_virtualenv(required = FALSE) and the DESCRIPTION-based mechanisms of the older recipes, which py_require() replaces. The fragment shows the recommended .onLoad() and an inst/python module; the package itself is built as in Creating Packages and its namespace works as in Packages, Libraries and Namespaces.

# R/zzz.R in a package that wraps a Python module (a fragment, not run).
isbn <- NULL

.onLoad <- function(libname, pkgname) {
  # reticulate::py_require("pandas") would go here if isbn.py needed it.
  path <- system.file("python", package = pkgname)
  isbn <<- reticulate::import_from_path("isbn", path = path, delay_load = TRUE)
}

is_valid_isbn13_py <- function(x) {
  vapply(x, isbn$is_valid_isbn13, logical(1), USE.NAMES = FALSE)
}

reticulate itself needs Python only at run time (its DESCRIPTION lists SystemRequirements: Python), so the package should also skip its Python-dependent tests when py_module_available() says the module is absent: testthat::skip_if_not(py_module_available("pandas")) is the usual form, as Testing with testthat describes for conditional skips. For reproducibility renv can record the Python side of a project as well: renv::use_python() creates a project-local Python environment and snapshot() and restore() then track a requirements.txt next to renv.lock (renv package documentation: Using Python with renv; see Reproducible Environments with renv).

Python in reports and apps

The same interpreter serves the other tools of this group. In R Markdown, reticulate provides the python chunk engine, which is enabled whenever reticulate is installed (knitr 1.18 and later) and shares one Python session across all the chunks of a document; R chunks reach Python objects through py, and Python chunks reach R objects through an r object. In Quarto, a document that has both python and r code blocks is run with the Knitr engine and the reticulate package executes the Python content (Quarto documentation: Execution Options). Reports and the python engine are the subject of Reports with Quarto and R Markdown. A Shiny app or a plumber API that calls Python through reticulate loads the interpreter once per R process, which is a design point for deployment: see Shiny, plumber and Web APIs, and for the equivalent in the other direction (a Python web service serving data-science code) Serving Data Science & AI. When the Python code is large or must run in another process, a small HTTP API (HTTP and Web APIs) or a file format such as JSON (Serialization: RDS, RData and JSON) between two programs is simpler than embedding, and it is the better choice whenever you cannot control the Python installation of the machine.

The same idea elsewhere: embedding and extending

reticulate is an embedding: the host language ® starts a Python interpreter in its own process and talks to it through the interpreter’s C API, converting values at the boundary. The reverse, an extension, is a library written in another language and loaded by Python as a module. Python Interop with PyO3 shows both for Rust: a #[pyfunction] compiled into an extension module that Python imports (extending), and a Rust program that starts an interpreter and calls into it (embedding), with the GIL and Python’s error types playing the part that the interpreter’s global state, the conversion rules and py_last_error() play here. The two pages answer the same design questions: who owns the interpreter, how do values cross, how do errors cross, and where does the memory live. In R the extension side is the job of Interfacing C, C++ and Fortran; for Fortran code that is called from Python the Fortran Reference has Calling Fortran from C, C++ and Python. And reticulate has a neighbour in the other direction: the rpy2 package embeds R in Python, which is the same arrangement with the roles exchanged.

The Python Reference pages that matter here are Numbers (the integers and floats of the conversion rules), Collections (lists, tuples, dicts and sets), Modules and Packages (what import() does) and Exceptions (what py_last_error() shows).

Pitfalls

Symptom Cause What to do

TypeError: 'float' object cannot be interpreted as an integer

An R double was sent where Python wants an int

Write 5L or as.integer(x).

A Python int comes back as NA, 0 or a wrong number

Ints outside the 32-bit range behave as if truncated in the automatic conversion

Use convert = FALSE and convert through float() or str(), or keep identifiers as strings.

AttributeError: 'list' object has no attribute ...

An R vector became a Python list; the function expected a scalar

Apply the function with vapply(), or change the Python function to loop.

ModuleNotFoundError for a package you installed

It was installed into a different interpreter

Check py_config(), install into that one, or py_require() it.

Python specified in RETICULATE_PYTHON ... does not exist

A stale path in .Renviron

Fix or remove the variable; it overrides every use_*() call.

Integer, logical or date NA turns into a number or False

No integer or logical missing value in NumPy

Use the nullable dtypes, convert dates to POSIXct, handle logical NA before the call.

A dict() key is a Python object, not a string

dict() looks the argument name up as a variable

Use py_dict() or rename the variable.

The result is transposed or scrambled

R and NumPy reshape in different orders

Use array_reshape(), or aperm(), as in the script above.

ValueError: assignment destination is read-only

The NumPy array is a view of R’s memory

np$array(m) makes a writable copy.

source_python() replaced an R function

The default envir is the calling environment

Pass envir = new.env() and use import_from_path() for libraries.

A use_python() call has no effect

RETICULATE_PYTHON is set (it always wins), or Python was already initialised by an earlier call

Call it before the first use of Python, and unset the variable.

A dtype error after upgrading pandas

reticulate and pandas versions out of step

Use a reticulate that supports your pandas (1.45.0 for pandas 3).

Version notes

The entries come from the NEWS of the package (reticulate NEWS); the package declares R 3.5 as its minimum version, nothing on this page depends on a feature added in R 4.x, and the NEWS records internal updates to follow changes in R’s C API (1.42.0 for R 4.5 and 1.46.0 for R 4.6, where it avoids R_NamespaceRegistry and R_UnboundValue in compiled code).

Version Change

1.29.0 (2023-06-05)

Python exceptions inherit from error and condition; py_last_error() gains R call information; Ops group generics for Python objects; py_run_file() and source_python() prepend the script directory to sys.path.

1.31 (2023-08-10)

reticulate no longer prompts to install Miniconda and prompts to create an r-reticulate venv instead; the order of discovery changed; virtualenv_starter(); [ and [<- call __getitem__ and __setitem__; periodic flushing of Python output.

1.32.0 (2023-09-11)

Opt-in conversion of R data frames to pandas nullable dtypes with reticulate.pandas_use_nullable_dtypes.

1.33.0

Compatibility with Python 3.12.

1.35.0 (2024-01-31)

Subclassed Python lists and dicts are no longer converted automatically.

1.36.0 (2024-04-15)

Faster attribute access; py_to_r() returns non-Python objects unchanged; source_python() no longer assigns r; POSIXt with a tzone converts to datetime.datetime.

1.38.0 (2024-06-19)

Python exceptions are R lists, not environments; support for NumPy 2.0 and NumPy string arrays.

1.39.0 (2024-09-05)

Python background threads run in parallel with R; py_main_thread_func() deprecated; RETICULATE_VIRTUALENV_ROOT.

1.40.0 (2024-11-15)

Python 3.13 supported; Positron Variables pane and repl_python().

1.41.0 (2025-02-24)

py_require() and uv-managed ephemeral environments; uv_run_tool(); a fix for Python 3.12 on Linux; raw arrays and NumPy V1 arrays convert.

1.42.0 (2025-03-25)

py_register_load_hook() exported; [ accepts indexes above 2^31; uv can resolve system Pythons.

1.43.0 (2025-07-21)

micromamba and mamba support; str(), dim() and t() methods for NumPy arrays; faster discovery by uv.

1.44.0 (2025-10-25)

Automatic cache clearing every 120 days; install_miniconda() installs miniforge; py_write_requirements() and py_read_requirements(); warnings for missing packages that were declared with py_require() (RETICULATE_CHECK_REQUIRED_PACKAGES); with() forwards errors to exit handlers.

1.44.1 (2025-11-14)

The default Python in install_python() and conda_create() is 3.12.

1.45.0 (2026-02-13)

pandas 3.0 support; direct conversion of pd.Categorical; py_require() allows adding already-required packages after initialisation.

1.46.0 (2026-04-09)

Internal updates for R 4.6 compiled-code API changes; py_list_packages() fixed for uv environments.

1.47.0 (2026-09-03)

exclude_newer accepts Date and POSIXct; RETICULATE_MAX_CACHE_AGE_DAYS; pandas frames with Arrow-backed string columns convert; explicit integer row names are preserved as the pandas index; Python calls to R convert the abort restart to a Python exception.

The 1.41.0 entry is the one that changed how a script is written: since then the first line of a script that needs Python packages is py_require(), not an installation command. 1.44.1 changes the Python that install_python() builds; the Python in this page’s image (3.12.3) is a system interpreter and was selected by RETICULATE_PYTHON. The dates are the CRAN release dates that the NEWS gives; it gives none for 1.33.0, and the date of 1.47.0 is its CRAN publication date.

Where older tutorials differ

Older tutorial writes Current practice Why Since

use_condaenv("r-reticulate") and Anaconda as the default Python

py_require(), or a venv chosen with use_virtualenv() or RETICULATE_PYTHON

Anaconda is no longer the default path; reticulate prompts for a venv, then manages an ephemeral one

1.31 and 1.41

py_install("pandas") at the top of every script

py_require("pandas")

py_install() changes the machine each time, and py_require() declares the dependency for the session without installing into a shared environment

1.41

reticulate::conda_create() and conda_install() in every project

py_require(), or virtualenv_create() when a persistent environment is needed

the vignettes now carry a banner of updated guidance against manual installations

1.41

install_miniconda() expected to give Miniconda

It installs miniforge

the default conda distribution changed

1.44.0

py_run_string() or repl_python() calls inside R chunks to run Python in a report

a python chunk with the reticulate engine, in R Markdown or Quarto

the engine shares one session between languages and is on by default

knitr 1.18

knitr::knit_engines$set(python = reticulate::eng_python)

nothing: the engine is registered when reticulate is installed

no setup chunk is needed

knitr 1.18

pandas < 2 dtype behaviour: object for strings, SettingWithCopyWarning, nanosecond datetimes everywhere

pandas 3: str dtype, copy-on-write, inferred datetime resolution

see What pandas 3.0 changed; pandas 3 needs reticulate 1.45 or later

pandas 3.0.0 and reticulate 1.45.0

The rPython and PythonInR packages

reticulate

both were removed from CRAN (rPython was archived on 2020-04-13 because of check problems; PythonInR on 2021-01-27 at the maintainer’s request, and its CRAN page recommends reticulate)

2020 and 2021

Sys.setenv(RETICULATE_PYTHON = ...) somewhere in the middle of a script

set it in .Renviron, or before anything uses Python

the variable is read when Python initialises; after that the interpreter is fixed for the session

always

The rPython and PythonInR entries are from the CRAN archive pages of the two packages (rPython and PythonInR).

Where to go next

References