{targets} is the most widely used pipeline toolkit in the R ecosystem and the de-facto standard for heavy-duty reproducible workflows. The table below contrasts the two packages to help you decide which one fits your project.
| Feature | targets | pipeflow |
|---|---|---|
| Paradigm | Declarative — define the full DAG upfront in a
_targets.R script, then execute |
Interactive — incrementally build the pipeline with
pip_add() as you code |
| Execution | tar_make() runs in a fresh R process
by default |
pip_run() runs in the current R
session |
| Persistent storage | ✅ Output stored to disk (_targets/objects/), survives
R restarts, handles data larger than RAM |
❌ In-memory only |
| Skip up-to-date steps | ✅ Hash-based invalidation of code and data | ✅ State-based (done / outdated) |
| Metadata & provenance | ✅ tar_meta() records runtime, size, errors per
target |
❌ No per-step provenance metadata (only the time of last execution) |
| Dependency validation | ✅ tar_validate() for pre-flight checks (opt-in) |
✅ On pip_add(), pip_replace(),
pip_remove() — fails fast on broken references |
| Modify pipeline at runtime | ❌ Must edit _targets.R and re-run |
✅ pip_add() insert with after =,
pip_replace(), pip_remove() |
| Parameter management | ❌ No unified parameter view across targets | ✅ pip_get_params() / pip_set_params() —
one call updates all steps |
| Split / map / reduce | ✅ pattern = map() / cross() built-in,
tarchetypes for advanced patterns |
✅ Built-in exec = "split" / "auto" /
"reduce" |
| Dynamic branching | ✅ Comprehensive via tarchetypes |
✅ Auto-mapping over partition keys
(exec = "auto") |
| Views / tag filtering | ✅ tar_described_as() selects by description tags |
✅ pip_view() and the [ operator — filter
by tags, step names, state, or boolean expressions |
| Pipeline composition | ❌ | ✅ rbind() any number of pipelines |
| Nested pipelines | ❌ | ✅ Steps can embed and run nested pipelines — reuse standalone pipelines as building blocks |
| Self-modifying pipelines | ❌ | ✅ Steps can modify the pipeline at runtime via .self
(add/remove/replace steps, set params) and restart the run with
.self$restart() |
| Distributed computing | ✅ crew for HPC and cloud workers |
❌ |
| Cloud storage | ✅ AWS, GCS | ❌ |
| File tracking | ✅ File targets with format = "file" |
❌ |
| Step locking | ❌ | ✅ pip_lock() / pip_unlock() — protect
steps from accidental modification |
| Visualization / monitoring | ✅ tar_visnetwork(), tar_glimpse(),
tar_watch() (live Shiny app) |
✅ pip_graph() → visNetwork |
In short, {targets} is the tool of choice for
large-scale reproducible projects: it persists results to disk, captures
provenance with tar_meta(), and scales to distributed
infrastructure via crew. {pipeflow}
prioritises interactivity: sub-millisecond skipped-step checks,
in-session pipeline modification, and low response times make it well
suited as a Shiny backend or for rapid parameter exploration during
analysis.
The comparison below is deliberately scoped. It does not claim that pipeflow is “faster at pipelines” in general. Instead it answers one defensible question: what is the in-session interactive latency of each package? — how long does a running R session take to (a) declare a pipeline, (b) check whether anything needs to run, and (c) refresh the affected steps after a change. This is the Shiny / parameter-exploration use case pipeflow is built for.
The cold end-to-end race — edit source, rebuild everything from
scratch, persist to disk — is targets’ home turf (disk persistence,
provenance, crew), so we report it separately and
explicitly do not frame it as a head-to-head.
Each operation is timed as its own, clearly scoped phase:
| Phase | pipeflow | targets |
|---|---|---|
| Define + build | pip_new() + pip_add() loop |
write _targets.R, then graph build
(tar_manifest()) |
| Skip-all | pip_run() — all steps done |
tar_make() — all targets up to date |
| Warm incremental | pip_set_params() + pip_run() |
edit parameter in _targets.R +
tar_make(callr_function = NULL) |
| Cold full run (reference) | pip_run(force = TRUE) |
tar_destroy() + default tar_make() (fresh
process) |
All phases run in the current R session, except the cold reference,
where targets uses its default fresh-process execution. targets
re-sources _targets.R and rebuilds its graph on
every tar_make() and tar_manifest()
call — that fixed per-run cost is part of its in-session latency and is
measured as such. pipeflow has no equivalent: its graph is the in-memory
object built once by pip_add().
bench::mark() is used instead of
system.time() + replicate(). It provides
adaptive iteration counts, a warmup, median / distribution statistics,
memory allocation and GC counts, and it randomizes the interleaving of
the compared expressions.tar_dir()), on the same fast local disk for every
run.check = FALSE is set because the two expressions return
different objects by design (a pipeflow object vs. the targets store
state).Before the timing, here are the operations being benchmarked on a three-step linear pipeline, to make the phases concrete.
# ---- pipeflow: linear pipeline --------------------------------------------
create_linear_pip <- function(n, flavor = "trivial", work = 0.005) {
pip <- pip_new("linear") |> pip_add("s0", \(init = 0) init)
for (i in seq_len(n)) {
if (flavor == "costly") {
pip_add(pip, step = sprintf("s%d", i),
\(x = ~ -1) {
Sys.sleep(work)
x + 1
})
} else {
pip_add(pip, step = sprintf("s%d", i), \(x = ~ -1) x + 1)
}
}
pip
}
# ---- targets: linear pipeline script --------------------------------------
linear_script <- function(n, flavor = "trivial", work = 0.005, init = 0) {
lines <- c(
"library(targets)",
"list(",
sprintf(" tar_target(s0, %s),", init)
)
for (i in seq_len(n)) {
prev <- sprintf("s%d", i - 1)
if (flavor == "costly") {
body <- sprintf(
paste0(
" tar_target_raw(\"s%d\", quote({ Sys.sleep(%s);",
" %s + 1 }))"
),
i, format(work, scientific = FALSE), prev
)
} else {
body <- sprintf(
" tar_target_raw(\"s%d\", quote(%s + 1))", i, prev
)
}
comma <- if (i == n) "" else ","
lines <- c(lines, paste0(body, comma))
}
c(lines, ")")
}
# ---- shared -----------------------------------------------------------------
tar_make_silent <- function() {
tar_make(callr_function = NULL, reporter = "silent")
}
# Alternates between 0 and 1 on every call, so each benchmark iteration
# invalidates the pipeline with a genuinely new parameter value.
param_value <- local({
k <- 0L
function() {
k <<- 1L - k
k
}
})
bench_tidy <- function(mark_res, n, phase) {
pkg <- as.character(mark_res$expression)
data.frame(
phase = phase,
n = n,
package = rep(pkg, times = lengths(mark_res$time)),
time = unlist(mark_res$time),
stringsAsFactors = FALSE
)
}
median_kable <- function(d, digits = 1) {
agg <- aggregate(time * 1000 ~ package + n + phase, d, median)
names(agg) <- c("package", "n", "phase", "median (ms)")
knitr::kable(agg, digits = digits)
}# pipeflow, trivial bodies
p <- create_linear_pip(3)
pip_run(p, lgr = NULL)
stopifnot(p[["s3", "out"]] == 3)
# pipeflow, costly bodies
p_costly <- create_linear_pip(3, flavor = "costly", work = 0.001)
pip_run(p_costly, lgr = NULL)
stopifnot(p_costly[["s3", "out"]] == 3)
# targets, trivial and costly bodies
tar_dir({
writeLines(linear_script(3), "_targets.R")
tar_make_silent()
stopifnot(tar_read(s3) == 3)
writeLines(linear_script(3, flavor = "costly", work = 0.001),
"_targets.R")
tar_make_silent()
stopifnot(tar_read(s3) == 3)
})The mini-pipelines build and run correctly in both packages, with trivial and costly step bodies alike. Now watch what happens after a change at the head of the chain:
# pipeflow: change one parameter, refresh affected steps
pip_set_params(p, list(init = 1))
print(p)
# <pipeflow> linear (4 steps)
# ---------------------------
# step params depends state out
# 1: s0 init outdated 0
# 2: s1 x s0 outdated 1
# 3: s2 x s1 outdated 2
# 4: s3 x s2 outdated 3
# ---------------------------
# <ready> last run: 2026-10-01 22:28:22
pip_run(p, lgr = NULL)
# pipeflow: everything up to date -> skip-all
pip_run(p, lgr = NULL)
# targets: edit the parameter value in _targets.R, refresh affected steps
tar_dir({
writeLines(linear_script(3, init = 1), "_targets.R")
tar_make_silent()
})Both packages re-run only the steps affected by the change (the whole chain, in a linear pipeline), and both skip everything when the pipeline is up to date. These are the two operations we time in phases 2 and 3.
How long does it take to declare a pipeline in R? For pipeflow, that
is the pip_add() loop itself. For targets, it is writing
the target script plus the graph build that tar_manifest()
performs — and note that targets pays this graph build on every
tar_make() call, not just at definition time.
| package | n | phase | median (ms) |
|---|---|---|---|
| pipeflow | 16 | define | 6.6 |
| targets | 16 | define | 860.0 |
| pipeflow | 32 | define | 13.1 |
| targets | 32 | define | 872.8 |
| pipeflow | 64 | define | 25.6 |
| targets | 64 | define | 940.5 |
| pipeflow | 128 | define | 51.2 |
| targets | 128 | define | 1042.6 |
Building the pipeline objects is roughly 40–50 times faster in
pipeflow at every size, and the gap grows with n. The
targets bar includes the parse and graph build of
_targets.R — the price of its declarative model, paid once
here and again on every run.
The closest thing to an apples-to-apples comparison: everything is already up to date, and we time a no-op run. This is the “sub-millisecond skip check” claim, measured directly.
# Warning: Some expressions had a GC in every iteration; so filtering is disabled.
| package | n | phase | median (ms) |
|---|---|---|---|
| pipeflow | 16 | skip | 0.4 |
| targets | 16 | skip | 46.8 |
| pipeflow | 32 | skip | 0.6 |
| targets | 32 | skip | 69.6 |
| pipeflow | 64 | skip | 1.0 |
| targets | 64 | skip | 121.9 |
| pipeflow | 128 | skip | 1.9 |
| targets | 128 | skip | 237.2 |
pipeflow stays below a millisecond for the skip check even at 128
steps. targets pays its per-run graph rebuild (~100–200 ms) even when no
target runs. That fixed cost is what a targets user pays on
every tar_make() in an interactive session — it
buys the persistent, provenance-tracking store.
The fair head-to-head for interactive work: a pipeline that is up to
date, a single parameter change, and the time until all affected steps
are refreshed. Each iteration changes the source value (pipeflow:
pip_set_params(); targets: editing the value in
_targets.R), so both packages re-run the same affected
steps.
# Warning: Some expressions had a GC in every iteration; so filtering is disabled.
# Warning: Some expressions had a GC in every iteration; so filtering is disabled.
| package | n | phase | median (ms) |
|---|---|---|---|
| pipeflow | 16 | incremental | 14.3 |
| targets | 16 | incremental | 76.2 |
| pipeflow | 32 | incremental | 27.7 |
| targets | 32 | incremental | 130.8 |
| pipeflow | 64 | incremental | 55.9 |
| targets | 64 | incremental | 247.4 |
| pipeflow | 128 | incremental | 111.5 |
| targets | 128 | incremental | 465.6 |
With trivial step bodies this is pure framework overhead: the graph
traversal and state bookkeeping in pipeflow, plus the per-run graph
rebuild and per-step bookkeeping in targets. pipeflow scales at roughly
1 ms per re-run step; targets pays a fixed graph rebuild on top of a
heavier per-step cost, so the gap stays roughly proportional as
n grows.
Trivial bodies are overhead, but overhead is only meaningful relative
to real work. So we repeat the incremental scenario with a fixed cost
per step — Sys.sleep(0.005) — so each full cascade performs
n * 5 ms of known work. This isolates the framework’s
contribution to a run that actually computes something.
# Warning: Some expressions had a GC in every iteration; so filtering is disabled.
# Warning: Some expressions had a GC in every iteration; so filtering is disabled.
| package | n | median (ms) | work (ms) | overhead (ms) | overhead share |
|---|---|---|---|---|---|
| pipeflow | 16 | 124.2 | 80 | 44.2 | 0.356 |
| targets | 16 | 179.4 | 80 | 99.4 | 0.554 |
| pipeflow | 32 | 251.7 | 160 | 91.7 | 0.364 |
| targets | 32 | 337.9 | 160 | 177.9 | 0.527 |
| pipeflow | 64 | 477.5 | 320 | 157.5 | 0.330 |
| targets | 64 | 610.7 | 320 | 290.7 | 0.476 |
| pipeflow | 128 | 984.2 | 640 | 344.2 | 0.350 |
| targets | 128 | 1244.2 | 640 | 604.2 | 0.486 |
Once steps do real work, the picture changes: the update time is dominated by the work itself, and the two packages land within ~30 % of each other. The framework contribution — about a third of the total for pipeflow and about half for targets, whose share includes its per-run graph rebuild — is exactly what the trivial-bodies benchmark isolates. This confirms that the benchmark measures interactive latency, not pipeline throughput.
For completeness, here is the cold end-to-end rebuild. It is not a head-to-head: pipeflow re-runs in a warm in-memory session, while targets destroys its store and runs in a fresh R process, persisting every object to disk with provenance. That is targets’ design and its home turf — the bar shows the magnitude of its cost model, not a defect.
| package | n | phase | median (ms) |
|---|---|---|---|
| pipeflow | 128 | cold | 119.4 |
| targets | 128 | cold | 2319.8 |
A source feeds n parallel branches that converge on one
sink. We time the same two phases — skip-all and warm incremental — with
trivial bodies.
dag_source <- function() 1
dag_branch <- function(x) x + 1
dag_sink <- function(...) sum(...)
make_branch_pip <- function(n) {
pip <- pip_new("dag") |>
pip_add("source", \(init = 0) dag_source() + init)
for (i in seq_len(n)) {
pip_add(pip, sprintf("b%d", i),
\(x = ~source) dag_branch(x))
}
args <- as.pairlist(stats::setNames(
lapply(paste0("b", seq_len(n)), \(s) {
stats::as.formula(paste0("~", s))
}),
paste0("x", seq_len(n))
))
body <- as.call(c(
list(as.symbol("dag_sink")),
lapply(paste0("x", seq_len(n)), as.symbol)
))
sink_fun <- eval(call("function", args, body))
pip_add(pip, "sink", sink_fun)
}
branch_script <- function(n, init = 0) {
branches <- vapply(seq_len(n), function(i) {
sprintf(
" tar_target_raw(\"b%d\", quote(dag_branch(source))),",
i
)
}, character(1))
sink_line <- sprintf(
" tar_target_raw(\"sink\", quote(dag_sink(%s)))",
paste(paste0("b", seq_len(n)), collapse = ", ")
)
c(
"library(targets)",
"dag_source <- function() 1",
"dag_branch <- function(x) x + 1",
"dag_sink <- function(...) sum(...)",
"list(",
sprintf(" tar_target(source, dag_source() + %s),", init),
branches,
sink_line,
")"
)
}p4 <- make_branch_pip(4)
pip_run(p4, lgr = NULL)
stopifnot(p4[["sink", "out"]] == 8)
tar_dir({
writeLines(branch_script(4), "_targets.R")
tar_make_silent()
stopifnot(tar_read(sink) == 8)
})# Warning: Some expressions had a GC in every iteration; so filtering is disabled.
# Warning: Some expressions had a GC in every iteration; so filtering is disabled.
# Warning: Some expressions had a GC in every iteration; so filtering is disabled.
| package | n | phase | median (ms) |
|---|---|---|---|
| pipeflow | 16 | incremental | 16.5 |
| targets | 16 | incremental | 98.6 |
| pipeflow | 32 | incremental | 28.4 |
| targets | 32 | incremental | 144.7 |
| pipeflow | 64 | incremental | 57.5 |
| targets | 64 | incremental | 260.4 |
| pipeflow | 128 | incremental | 125.0 |
| targets | 128 | incremental | 478.7 |
| pipeflow | 16 | skip | 0.4 |
| targets | 16 | skip | 57.6 |
| pipeflow | 32 | skip | 0.6 |
| targets | 32 | skip | 76.5 |
| pipeflow | 64 | skip | 1.0 |
| targets | 64 | skip | 135.2 |
| pipeflow | 128 | skip | 1.9 |
| targets | 128 | skip | 244.5 |
Wide graphs do not change the picture: skip checks stay sub-millisecond in pipeflow, and the incremental update scales with the number of re-run steps while targets carries its fixed per-run rebuild.
The bars above are not all measuring the same thing. This table states explicitly what each phase does and does not include.
| Bar | Step bodies run | Process | Disk persistence | Provenance | Store |
|---|---|---|---|---|---|
| pipeflow skip-all | none | current session | no | no | in-memory |
| targets skip-all | none | current session | yes | yes | on-disk _targets/ |
| pipeflow incremental | affected steps | current session | no | no | in-memory |
| targets incremental | affected steps | current session | yes | yes | on-disk _targets/ |
| pipeflow cold (ref) | all steps | current session | no | no | in-memory |
| targets cold (ref) | all steps | fresh R process | yes | yes | on-disk _targets/ |
In short: targets’ overhead buys disk persistence, provenance, and distributed execution; pipeflow’s buys in-session latency. The benchmark above is a latency comparison, and the numbers only make sense with that caveat attached.
crew workers, will win the only race that matters
for heavy workloads — and pipeflow will never claim that ground.# R version 4.5.3 (2026-03-11)
# Platform: x86_64-pc-linux-gnu
# Running under: Ubuntu 24.04.4 LTS
#
# Matrix products: default
# BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
# LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.26.so; LAPACK version 3.12.0
#
# locale:
# [1] LC_CTYPE=C.UTF-8 LC_NUMERIC=C LC_TIME=C.UTF-8 LC_COLLATE=C.UTF-8
# [5] LC_MONETARY=C.UTF-8 LC_MESSAGES=C.UTF-8 LC_PAPER=C.UTF-8 LC_NAME=C
# [9] LC_ADDRESS=C LC_TELEPHONE=C LC_MEASUREMENT=C.UTF-8 LC_IDENTIFICATION=C
#
# time zone: Etc/UTC
# tzcode source: system (glibc)
#
# attached base packages:
# [1] stats graphics grDevices datasets utils methods base
#
# other attached packages:
# [1] ggplot2_4.0.3 bench_1.1.4 targets_1.12.0 pipeflow_0.4.0 testthat_3.3.2
#
# loaded via a namespace (and not attached):
# [1] sass_0.4.10 generics_0.1.4 renv_1.2.3 digest_0.6.39
# [5] magrittr_2.0.5 evaluate_1.0.5 grid_4.5.3 RColorBrewer_1.1-3
# [9] pkgload_1.5.2 fastmap_1.2.0 jsonlite_2.0.0 rprojroot_2.1.1
# [13] processx_3.9.0 pkgbuild_1.4.8 sessioninfo_1.2.3 backports_1.5.1
# [17] brio_1.1.5 secretbase_1.2.2 ps_1.9.3 BiocManager_1.30.27
# [21] purrr_1.2.2 scales_1.4.0 codetools_0.2-20 jquerylib_0.1.4
# [25] cli_3.6.6 rlang_1.2.0 ellipsis_0.3.3 withr_3.0.2
# [29] cachem_1.1.0 yaml_2.3.12 devtools_2.5.2 otel_0.2.0
# [33] tools_4.5.3 memoise_2.0.1 dplyr_1.2.1 base64url_1.4
# [37] profmem_0.7.0 vctrs_0.7.3 R6_2.6.1 lifecycle_1.0.5
# [41] fs_2.1.0 usethis_3.2.1 callr_3.7.6 pkgconfig_2.0.3
# [45] desc_1.4.3 pillar_1.11.1 bslib_0.11.0 gtable_0.3.6
# [49] data.table_1.18.6.1 glue_1.8.1 Rcpp_1.1.1-1.1 xfun_0.57
# [53] tibble_3.3.1 tidyselect_1.2.1 rstudioapi_0.18.0 knitr_1.51
# [57] farver_2.1.2 igraph_2.3.2 htmltools_0.5.9 labeling_0.4.3
# [61] rmarkdown_2.31 compiler_4.5.3 prettyunits_1.2.0 S7_0.2.2