pipeflow vs targets

Overview

{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 benchmark: what we measure

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().

Measurement method

The operations, in miniature

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.

Phase 1 — Define and build

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.

Phase 2 — Skip-all

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.

Phase 3 — Warm incremental update

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.

Trivial step bodies

# 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.

Work-cost control

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.

Phase 4 — Cold full run (reference, not a race)

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

Does the story change with wide graphs?

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.

What the numbers include

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.

Summary

Session info

# 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