Get started with tarpolyglot

tarpolyglot lets you run Python, Julia, and Rust code as ordinary steps of a targets pipeline. It adds three pairs of target constructors that mirror targets::tar_target() / targets::tar_target_raw():

Language Non-standard eval (in _targets.R) Raw (inside factories) Bridge
Python tar_target_py() tar_target_py_raw() reticulate
Julia tar_target_jl() tar_target_jl_raw() JuliaCall
Rust tar_target_rs() tar_target_rs_raw() rextendr / extendr

Every code block below is shown for illustration and is not executed when the vignette is built (running it would need a live Python/Julia/Rust toolchain and a _targets.R project). Copy the pieces into your own pipeline.

Motivation

If you already work in targets, you usually want to keep all of its machinery (dynamic branching, cues and invalidation, storage formats, crew-based parallelism, tarchetypes) while occasionally dropping into Python or Julia for a step or two, or writing a hot function in Rust. rixpress is a really good take on polyglot, reproducible pipelines, but it builds those pipelines on Nix-managed environments which could be difficult to implement in some case (e.g. a public cluster on which you can’t install nix, a windows computer on which you don’t wish to install wsl).

Doing that by hand means, in every single step, repeating the same plumbing: initialise reticulate against the right environment, push your R objects into Python, run the script, convert the results back, and the equivalent dance for JuliaCall, or the rextendr::rust_source() setup for Rust. tarpolyglot moves that boilerplate into the constructor. You point at a script, say which upstream targets feed it and what to read back, and you get a normal targets target:

library(targets)
library(tarpolyglot)

list(
  tar_target(values, c(2, 4, 6, 8)),
  tar_target_py(
    name = py_mean,
    script = "py/mean.py",           # your Python, unchanged
    inputs = c(x = "values"),        # wire in an upstream target
    pre_script = "R/push.R",         # hand `x` to Python
    retrieve = "result"              # read the Python `result` back into R
  )
)

No reticulate::use_*(), no manual conversion, no bespoke wrapper per step, and the environment/version selection (virtualenv, venv, uv, poetry, conda for Python; juliaup and Julia projects for Julia; rustup toolchains for Rust) is just another argument. You keep every targets feature and pick your own environment manager, without adopting a separate build tool such as Nix.

Installation

# install.packages("remotes")
remotes::install_local("path/to/tarpolyglot", build_vignettes = TRUE)

You then need a working toolchain only for the language(s) you actually use (each is independent):

  • Python: any recent CPython, plus install.packages("reticulate"). reticulate can also provision an ephemeral Python for you via uv.
  • Julia: install via juliaup, plus install.packages("JuliaCall").
  • Rust: install the toolchain with rustup (on Windows use the GNU toolchain), plus install.packages("rextendr").

See the per-language vignettes for the details: vignette("python"), vignette("julia"), vignette("rust").

Limitations to know first

tarpolyglot is a thin bridge, so it inherits the constraints of the runtimes it wraps. The important ones:

  1. One interpreter per R session. reticulate binds a single Python per R process, and JuliaCall a single Julia, fixed on the first call. A second step in the same session that asks for a different environment silently runs in the first one. To use different environments in one pipeline, put those steps on separate crew workers (see below). (Rust is exempt: each Rust step compiles its own library, so different steps can use different crates freely.)

  2. Foreign global state is shared within a session. Your Python script runs in Python’s top-level module, __main__, and your Julia script in Julia’s, Main. Those namespaces belong to the interpreter, not to the step, so steps sharing one R session share them too: a later step does not start from a clean slate, it reuses the interpreter an earlier step already bound, variables and all. Beware of three similar-looking names here: Python’s __main__ and Julia’s Main are foreign-language modules, while deployment = "main" refers to the main R process, and that is where this bites most often, because targets runs every main-deployed step in that one session. Giving each step its own crew worker avoids it (point 3). Either way, never rely on those leftovers: pass data in explicitly (inputs + to_py/to_jl) and read results back with retrieve or a post-script.

  3. Start-up cost vs isolation. A fresh process re-initialises the interpreter (and, on the default ephemeral env, may fetch packages). The recommended polyglot_controller() gives each target a fresh interpreter (tasks_max = 1) for maximum isolation; raise tasks_max to reuse interpreters for throughput, at the cost of the shared-session caveats in points 1–2.

  4. Only serialisable R values cross target boundaries. A step can return an R object produced by conversion, or file paths (output = "file"). A live Python/Julia object (an open handle, an in-memory model) cannot be a target value. Or you can instead write it to disk in one step and load it in the next.

  5. Reproducibility is on you. The default ephemeral Python drifts over time; pin a real environment (env + env_manager, or python) and, for Julia, a julia_project with a committed Manifest.toml. You should also make sure to use some targets steps to keep track of your script files.

  6. A crew worker is a full process, not a slice of one. It is not restricted to a single CPU core the way a lightweight thread might be, it sees every core the machine has, exactly like the main session. This matters when a step itself does internal multi-core work (RcppParallel, Python’s multiprocessing, Julia’s Distributed): running several such steps at once on separate workers means each one may try to claim every core, so N concurrent workers each spawning a full-width thread or process pool can genuinely oversubscribe the machine, degrading performance or, on a machine with few cores, exhausting it. deployment = "main" on the specific step that does the internal parallel work is the fix: targets only ever builds one main-deployed target at a time, so those steps never compound with each other, while ordinary worker-deployed steps keep running alongside them. See vignette("cpp"), vignette("python"), and vignette("julia") for the working pattern in each language.

A worked pipeline (Python + iris)

This section builds one small pipeline that exercises the features you will use most: passing data (including a reproducible seed) into Python, reading variables back in a post-script, tracking the scripts as dependencies, and returning results either as an in-pipeline object or as files on disk.

The three-script model

A Python (or Julia) step is up to three scripts:

 upstream targets ─► pre_script (R) ─► script (.py) ─► post_script (R) ─► target value
                     builds `to_py`     computes         reads `py$name` /
                                        `result`         `py_get("name")`
  • script (required): your Python/Julia file.
  • pre_script (optional R): runs first; the inputs are already bound by name. Assign a named list to_py (Python) / to_jl (Julia) to push objects into the foreign session.
  • post_script (optional R): runs last; reads results back. Its last expression becomes the target value (object mode), or it returns file paths (file mode).

Object output: return a value into the pipeline

py/iris_kmeans.py (a small, seeded computation on iris):

import numpy as np

# `df` (a pandas DataFrame) and `seed` (an int) were pushed from R.
rng = np.random.default_rng(seed)
X = df[["Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width"]].to_numpy()

# pick 3 random initial centres, reproducibly (same seed -> same pick)
idx = rng.choice(X.shape[0], size=3, replace=False)
result = {"n": int(X.shape[0]), "centers": X[idx].tolist()}

R/iris_pre.R: the pre-script builds to_py, and gets the target’s reproducible seed straight from targets:

# `df` is bound from inputs = c(df = "iris_data").
# tar_seed_get() returns THIS target's deterministic seed (derived from the
# pipeline seed and the target name), so the Python RNG is reproducible.
to_py <- list(
  df   = df,
  seed = targets::tar_seed_get()
)

R/iris_post.R: the post-script reads variables back. tarpolyglot binds py (the reticulate __main__ proxy) and py_get(name) (an explicit reticulate::py_to_r() shortcut). The last expression is the target’s value:

res <- py_get("result")               # or: res <- py$result
data.frame(n = res$n, k = length(res$centers))

_targets.R:

library(targets)
library(tarpolyglot)

list(
  tar_target(iris_data, iris),

  tar_target_py(
    name = km,
    script = "py/iris_kmeans.py",
    inputs = c(df = "iris_data"),
    pre_script = "R/iris_pre.R",
    post_script = "R/iris_post.R"       # returns a data.frame into the pipeline
  )
)

If all you need is to read one or more variables verbatim (no reshaping), drop the post-script and use retrieve instead: it does the py_get() lookup for you:

tar_target_py(
  name = km,
  script = "py/iris_kmeans.py",
  inputs = c(df = "iris_data"),
  pre_script = "R/iris_pre.R",
  retrieve = "result"                   # returns the Python `result` as an R list
)

File output: return paths to files on disk

When the script writes its output (a CSV, a model, a figure), set output = "file". The target format becomes "file" automatically, and targets tracks the returned paths by hash.

py/iris_write.py:

import pandas as pd
out_path = "out/iris_summary.csv"
df.groupby("Species").mean().to_csv(out_path)   # `df` pushed from R

R/iris_files_post.R (returns the path(s) the step produced):

py_get("out_path")                      # a character vector of file paths
tar_target_py(
  name = iris_csv,
  script = "py/iris_write.py",
  inputs = c(df = "iris_data"),
  pre_script = "R/iris_pre.R",
  post_script = "R/iris_files_post.R",
  output = "file"                        # target tracks the CSV on disk
)

Use file mode for anything that does not round-trip cleanly through conversion (large arrays, models, non-convertible objects): write it in one step, load it in the next step’s pre_script.

Tracking the scripts as dependencies

By default script / pre_script / post_script are plain literal paths: targets embeds them as constants, so editing the file does not invalidate the target. To have the step re-run when a script changes, track that script with its own format = "file" target and reference it with tar_target_path():

list(
  tar_target(iris_data, iris),

  # each script tracked as a file target
  tar_target(kmeans_py,  "py/iris_kmeans.py", format = "file"),
  tar_target(pre_R,      "R/iris_pre.R",      format = "file"),
  tar_target(post_R,     "R/iris_post.R",     format = "file"),

  tar_target_py(
    name = km,
    script      = tar_target_path("kmeans_py"),   # re-runs when the .py changes
    pre_script  = tar_target_path("pre_R"),
    post_script = tar_target_path("post_R"),
    inputs = c(df = "iris_data")
  )
)

tar_target_path("name") takes the name of the upstream file target (exactly like inputs = c(x = "some_target")), so targets’ normal dependency detection picks it up, so there is no need to also list it in deps.

Parallelism and isolation with crew

Because of limitation 1 (one interpreter per session), the clean way to give each step a fresh interpreter, and to let different steps use different environments, is to run them in separate worker processes with crew. tarpolyglot ships a controller preconfigured for this:

tar_option_set(controller = polyglot_controller(workers = 2L))

polyglot_controller() wraps crew::crew_controller_local() with tasks_max = 1L: each worker runs one task then retires, so every foreign step starts from a brand-new interpreter. With a controller set, tarpolyglot targets (whose deployment defaults to "worker") run on workers automatically.

Foreign steps on workers, ordinary R steps on the main process. This is the common setup: the heavy R work stays in-process, and only the Python/Julia/Rust steps go to the controller. Flip the global default to deployment = "main" and override just the foreign steps back to "worker":

tar_option_set(
  controller = polyglot_controller(workers = 1L),
  deployment = "main"                    # every ordinary tar_target() stays on main
)

list(
  tar_target(iris_data, iris),           # main process
  tar_target_py(
    name = km,
    script = "py/iris_kmeans.py",
    inputs = c(df = "iris_data"),
    pre_script = "R/iris_pre.R",
    retrieve = "result",
    deployment = "worker"                # THIS step runs on the polyglot worker
  )
)

The per-step deployment = "worker" is required here: without it the Python step would also inherit "main" and run in-process, with no interpreter isolation.

Foreign and R steps on different workers. Use a named crew::crew_controller_group() and route each target by controller name; set the R controller as the default via resources so ordinary targets need no extra argument:

tar_option_set(
  controller = crew::crew_controller_group(
    polyglot_controller(name = "poly", workers = 2L),          # Python/Julia/Rust
    crew::crew_controller_local(name = "rmain", workers = 4L)  # ordinary R targets
  ),
  resources = tar_resources(crew = tar_resources_crew(controller = "rmain"))
)

list(
  tar_target(iris_data, iris),           # -> "rmain" (the default)
  tar_target_py(
    name = km,
    script = "py/iris_kmeans.py",
    inputs = c(df = "iris_data"),
    pre_script = "R/iris_pre.R",
    retrieve = "result",
    resources = tar_resources(crew = tar_resources_crew(controller = "poly"))
  )
)

Dynamic branching works unchanged: inputs become real dependencies, so pattern branches them, and crew spreads the branches across workers (each a fresh interpreter under tasks_max = 1):

list(
  tar_target(iris_groups, split(iris, iris$Species), iteration = "list"),  # 3 groups
  tar_target_py(
    name = per_species,
    script = "py/iris_kmeans.py",
    inputs = c(df = "iris_groups"),
    pre_script = "R/iris_pre.R",
    retrieve = "result",
    pattern = map(iris_groups),          # one branch per species, in parallel
    iteration = "list"
  )
)

Per-step log files. polyglot_controller() accepts a log argument (built with tar_polyglot_log()) that writes one stdout/stderr file per Python or Julia step, named after the target:

tar_option_set(
  controller = polyglot_controller(
    workers = 2L,
    log = tar_polyglot_log(stdout = "logs/out", stderr = "logs/err")
  )
)

The km step above would then get logs/out/km.out / logs/err/km.err, starting with a header (the step name, date(), the resolved interpreter’s version and path, and whether an explicit environment was used) followed by whatever the script itself printed. By default (append = FALSE) a step’s log is truncated at the start of its run, so it only ever holds the latest attempt; set append = TRUE to accumulate history instead, with new content separated from old by two blank lines – this also means that if crew silently retries a step after a worker crash, both attempts show up in the same file, which can be useful for debugging.

tar_polyglot_log() covers Python and Julia only, not Rust: rextendr-compiled code writes straight to the OS file descriptor, bypassing the redirection reticulate/JuliaCall provide for their embedded interpreters – and Rust doesn’t pay a per-branch interpreter start-up cost the way Python/Julia do to begin with, so there is less need for a per-step Rust log (see tarpolyglot_map() in vignette("rust") for how a Rust step’s compiled library is instead built once and reused across branches). Use crew’s own options_local(log_directory = ...) for Rust step output; it also works for Python/Julia when log is left unset, since tarpolyglot never spawns a subprocess for them – reticulate/JuliaCall’s output shares the worker process’s own stdout/stderr, so crew’s process-level capture sees it too. Setting both is fine, and the two are complementary rather than redundant: options_local(log_directory = ...) also captures ordinary R-level output (pre/post-script messages, warnings) that tar_polyglot_log() does not touch, while tar_polyglot_log()’s file is named after the step rather than a crew-generated worker name. They do split the foreign output between the two files rather than duplicate it, though: while a step’s script body is running, tar_polyglot_log() diverts the foreign print output specifically into its own file, so for that window it will not also appear in crew’s log.

One caveat under dynamic branching: every branch of a pattern target shares the same target name, so with append = FALSE (the default) each branch’s log write truncates whatever the previous branch wrote – only the last-finishing branch’s log survives. Set append = TRUE if you need every branch’s output; they land in one file, interleaved by finish order, not split into one file per branch.

See vignette("python"), vignette("julia"), and vignette("rust") for the full per-language reference, and ?polyglot_controller / ?tar_polyglot_log for the controller and logging options.

Pre- and post-scripts: moving variables in and out

The pre- and post-scripts are the two hooks where you move data between R and the foreign session. They are ordinary R scripts, evaluated in the step’s environment.

Sending variables in (the pre-script)

The pre-script runs before the foreign script, with the inputs already bound by name. If it assigns a named list called to_py (Python) or to_jl (Julia), tarpolyglot pushes each element into the foreign session:

  • Python: each element becomes a top-level variable in the __main__ module (via reticulate’s conversion rules).
  • Julia: each element is julia_assign()ed as a variable in Main.
# R/iris_pre.R  (inputs = c(df = "iris_data"))
to_py <- list(
  df   = df,                       # R data.frame -> pandas DataFrame
  seed = targets::tar_seed_get(),  # reproducible integer for the Python RNG
  k    = 3L                        # note the L: Python wants an int, not a double
)

Check the reticulate / JuliaCall conversion rules for which R types round-trip; for anything that does not, use file mode.

Reading variables out (the post-script)

The post-script runs after the foreign script. tarpolyglot binds a few helpers into its environment:

  • Python steps: py, the reticulate __main__ proxy (py$result), and py_get("result"), a shortcut for reticulate::py_to_r(py[["result"]]) that forces an explicit conversion.
  • Julia steps: jl_get("name"), a shortcut for JuliaCall::julia_eval("name") (JuliaCall has no py-style proxy), and jl_call, an alias of JuliaCall::julia_call().
# R/iris_post.R  (Python)
res <- py_get("result")
data.frame(n = res$n, k = length(res$centers))   # last expression = target value

Rust is different: it has no pre-script. The #[extendr] functions are compiled and then called directly from the post-script, with the inputs in scope. See vignette("rust").

Where to next

  • vignette("python"): every tar_target_py() argument, choosing an environment (system / virtualenv / venv / uv / poetry / conda) or a python_version, with use-case examples.
  • vignette("julia"): the same for Julia (julia_version, julia_project, julia_packages).
  • vignette("rust"): Rust steps via rextendr/extendr.
  • ?tar_target_py, ?tar_target_jl, ?tar_target_rs, ?tar_target_path, ?polyglot_controller: the function reference.