plumber vs plumber2

What changed in the Plumber API framework

APIs
Shiny
plumber
plumber2
Author

Martin Frigaard

Published

July 21, 2025

I’ve been working through the exercises in Alex K Gold’s DevOps for Data Science and this week I finished the API labs. When the book was written, plumber2 hadn’t been released yet. I went ahead and completed the exercises using both plumber and plumber2.1

In this post, we’ll explore:

  1. What plumber and plumber2 do
  2. The syntax changes between versions (with side-by-side comparisons)
  3. A working example using a penguin prediction model
  4. Logging with both versions
  5. Why the migration might be worth it

What is plumber?

Both plumber and plumber2 turn R functions into REST APIs. We can also use them to write handlers that respond to HTTP requests, wire them into a router, and launch a server. Clients (Shiny apps, JavaScript frontends, Python scripts) make HTTP requests and get JSON responses back.

%%{init: {'theme': 'base', 'themeVariables': {'fontFamily': 'monospace', 'darkMode': true}}}%%

flowchart TD
    Shiny(["Shiny App"])
    Request["POST /predict<br/>(penguin data)"]
    Router["Plumber Router"]
    Match["Route Match"]
    Handler["handle_predict()"]
    Result["list(.pred = 3897)"]
    JSON["JSON Response"]
    Return(["Return to Shiny"])

    Shiny -->|send| Request
    Request --> Router
    Router --> Match
    Match -->|dispatch| Handler
    Handler --> Result
    Result -->|serialize| JSON
    JSON --> Return
    Return --> Shiny

    style Shiny fill:#FFF8E7,stroke:#999,color:#000000
    style Match color:#000000,fill:#FFF8E7,stroke:#999
    style Result color:#000000,fill:#FFF8E7,stroke:#999
    style Return color:#000000,fill:#FFF8E7,stroke:#999
    style Router fill:#4CBB9D,stroke:#4CBB9D,color:#FFFFFF,rx:5,ry:5
    style Handler fill:#E0F0ED,stroke:#4CBB9D,color:#000000,rx:5,ry:5
    style Request fill:#FFFFFF,stroke:#333,color:#000000
    style JSON fill:#FFFFFF,stroke:#333,color:#000000

How Plumber APIs Work

To get started:

install.packages("plumber2")

Key changes

The major differences between plumber and plumber2 are syntactic. The architecture and concepts are the same; the code looks different.

Comparison table

Task plumber plumber2
Create router pr() api()
Add GET route pr_get() api_get()
Add POST route pr_post() api_post()
Start server pr_run() api_run()
JSON serializer serializer_json() format_json()
Request body jsonlite::fromJSON(req$postBody) body argument (auto-parsed)
Response status res$status <- 500 response$status <- 500L
Annotation comment #' #*

Parameter handling

This is the biggest practical change. In plumber, all request data (query, body, path) mixes into function arguments. We parsed the body manually.

%%{init: {'theme': 'base', 'themeVariables': {'fontFamily': 'monospace', 'darkMode': true}}}%%

flowchart LR
    subgraph Plumber["<strong><code>plumber</code></strong>"]
        direction TB
        ReqPlumb("HTTP Request")
        ParsePlumb["<code>query</code>, <code>body</code>, <code>path</code> into function<br/>args"]
        ManualPlumb(["<code>req$postBody</code><br/>+ jsonlite"])
        ReqPlumb --> ParsePlumb
        ParsePlumb --> ManualPlumb
    end

    style Plumber stroke:#999,color:#000000,rx:5,ry:5
    style ReqPlumb fill:#FFFFFF,stroke:#333,color:#000000
    style ParsePlumb fill:#FFF8E7,stroke:#999,color:#000000
    style ManualPlumb fill:#FFE0E0,stroke:#c0392b,color:#000000
    

Parameter Routing: plumber

In plumber2, they’re separated:

  1. The path parameters are now named function arguments (e.g., /users/<id>function(id))
  2. Query parameters are accessed via the query argument
  3. The request body is accessed via the body argument (already parsed JSON)
  4. Response control uses the response argument to set status codes

%%{init: {'theme': 'base', 'themeVariables': {'fontFamily': 'monospace', 'darkMode': true}}}%%

flowchart LR
    subgraph Plumber2["<strong><code>plumber2<code></strong>"]
        direction TB
        ReqP2("HTTP Request")
        PathP2(["<code>path</code><br/>(named args)"])
        QueryP2(["<code>query</code><br/>(auto)"])
        BodyP2(["<code>body</code><br/>(auto-parsed)"])
        ReqP2 --> PathP2
        ReqP2 --> QueryP2
        ReqP2 --> BodyP2
    end

    style Plumber2 stroke:#999,color:#000000,rx:5,ry:5
    style ReqP2 fill:#FFFFFF,stroke:#333,color:#000000
    style PathP2 fill:#E0F0ED,stroke:#4CBB9D,color:#000000,rx:5,ry:5
    style QueryP2 fill:#E0F0ED,stroke:#4CBB9D,color:#000000,rx:5,ry:5
    style BodyP2 fill:#E0F0ED,stroke:#4CBB9D,color:#000000,rx:5,ry:5
    

Parameter Routing: plumber2

Example: Penguin prediction API

Both book chapters use the same example: building an API that predicts penguin body mass from bill length, species, and sex. Let’s see how the syntax differs for the same handler.

Helper function (identical in both)

Both versions use this helper to convert JSON strings to factors (which the model expects):

prep_pred_data <- function(input_data) {
  species_levels <- levels(v$prototype$species)
  sex_levels <- levels(v$prototype$sex)

  data.frame(
    bill_length_mm = as.numeric(input_data$bill_length_mm),
    species = factor(input_data$species, levels = species_levels),
    sex = factor(input_data$sex, levels = sex_levels),
    stringsAsFactors = FALSE
  )
}
1
Extract factor levels from the vetiver model prototype

plumber version

From the plumber.R file:

show/hide plumber handle_predict()
#* Predict penguin body mass
#*
#* Main prediction endpoint that accepts penguin characteristics and returns
#* predicted body mass in grams.
#*
#* @post /predict
#* @serializer json
handle_predict <- function(req, res) {

  result <- tryCatch({
    body <- jsonlite::fromJSON(rawToChar(req$postBody))

    if (is.list(body) && !is.data.frame(body)) {
      body <- as.data.frame(body)
    }

    pred_data <- prep_pred_data(body)
    prediction <- predict(v, pred_data)

    list(.pred = as.numeric(prediction))

  }, error = function(e) {
    res$status <- 400
    list(error = e$message)
  })

  result
}
1
Handler receives raw request and response objects
2
Manual JSON parsing from request body
3
Prepare data with factor conversion
4
Make prediction using vetiver model
5
Set HTTP status on error

plumber2 version

From the plumber2.R file:

show/hide plumber2 handle_predict()
#* Predict penguin body mass
#*
#* @post /predict
#* @serializer json
handle_predict <- function(body, response) {

  result <- tryCatch({

    if (is.list(body) && !is.data.frame(body)) {
      body <- as.data.frame(body)
    }

    pred_data <- prep_pred_data(body)
    prediction <- predict(v, pred_data)

    if (is.data.frame(prediction) && ".pred" %in% names(prediction)) {
      list(.pred = prediction$.pred)
    } else if (is.numeric(prediction)) {
      list(.pred = as.numeric(prediction))
    }

  }, error = function(e) {
    response$status <- 400L
    list(error = e$message)
  })

  result
}
1
Handler receives parsed body and response objects directly
2
Body is already parsed; no manual JSON conversion needed
3
Prepare data with factor conversion
4
Make prediction using vetiver model
5
Set HTTP status on error

Testing with curl

Both APIs respond identically:

curl -X POST http://localhost:8000/predict \
  -H "Content-Type: application/json" \
  -d '{"bill_length_mm": 45, "species": "Adelie", "sex": "male"}'
{
  ".pred": [3897.5]
}

The key difference: plumber requires us to parse req$postBody manually; plumber2 provides body already parsed. For everything else, the logic is identical.

Logging

Both versions work with the logger package, and the calls look the same either way. Here’s a health check endpoint that writes a line every time it’s hit:

#* Basic health check
#* @get /ping
#* @serializer json
handle_ping <- function() {
  logger::log_info("Health check requested")
  list(status = "alive", timestamp = Sys.time())
}

Swap library(plumber) for library(plumber2) and this handler doesn’t change. The difference only shows up when we want request-level detail in the message: plumber makes us dig it out of req, while plumber2 hands us body and response as arguments (covered in the parameter handling section above).

The logs I actually reach for come from the client side. In the logging lab I used logger instead of the book’s suggested log4r, mostly because I’m more familiar with its features.2 Configuration is three calls: set the threshold, point the appender at a file, and pick a formatter.

logger::log_threshold(level = "DEBUG")
logger::log_appender(appender = appender_tee(file = "shiny_app.log"))
logger::log_formatter(logger::formatter_glue_or_sprintf)

appender_tee() writes to both the console and shiny_app.log, and the glue/sprintf formatter keeps messages readable when we display them back in the UI.

Session startup and user input

Session startup goes in an observer with a high priority so it runs before anything else in the server function:

observe({
  logger::log_info("Shiny app started - Session: {session$token} - Host: {session$clientData$url_hostname}")
}, priority = 1000)

Input changes are worth capturing at DEBUG, but an unthrottled observer fires on every keystroke. throttle() caps it at one message every two seconds:

observe({
  logger::log_debug("User input changed - Session: {session$token} - bill_length: {input$bill_length} - species: {input$species} - sex: {input$sex}")
}) |>
  throttle(2000)

Logging the API request

This is where the logs earn their keep. The prediction reactive wraps the httr2 call in tryCatch(), measures how long the API took to answer, and writes a success line, a slow-response warning, or an error:

show/hide pred() with response-time logging
pred <- reactive({
  request_start <- Sys.time()
  request_data <- vals()

  logger::log_info(
    "Starting prediction request - Session: {session$token} - request_data: {jsonlite::toJSON(request_data, auto_unbox = TRUE)}"
  )

  tryCatch({
    response <- httr2::request(api_url) |>
      httr2::req_method("POST") |>
      httr2::req_body_json(request_data, auto_unbox = TRUE) |>
      httr2::req_timeout(30) |>
      httr2::req_perform()

    response_time <- as.numeric(difftime(Sys.time(), request_start, units = "secs"))
    response_data <- httr2::resp_body_json(response)
    prediction_value <- as.numeric(response_data$.pred[[1]])

    logger::log_info(
      "Prediction successful - Session: {session$token} - response_time_sec: {round(response_time, 3)} - prediction: {prediction_value}"
    )

    if (response_time > 5) {
      logger::log_warn("Slow API response - Session: {session$token} - response_time_sec: {response_time}")
    }

    prediction_value
  }, error = function(e) {
    logger::log_error(
      "Prediction request failed - Session: {session$token} - error: {conditionMessage(e)}"
    )
    paste("Error:", conditionMessage(e))
  })
}) |>
  bindEvent(input$predict, ignoreInit = TRUE)

Three levels do the work. INFO records the outgoing request and the successful prediction with its response time, WARN flags anything slower than five seconds (a threshold worth tuning to the API), and ERROR captures the condition message when the request never comes back.

The health check follows the same shape: the app pings /ping and logs at DEBUG when the check starts, INFO on a 200, WARN on any other status, and ERROR when the request throws. And because appender_tee() is writing to a file, we can read that file back into the app with reactiveFileReader(), poll it every second, and show the last five log lines in the UI.

Every request writes a session-scoped trail, with the level chosen by what actually happened:

%%{init: {'theme': 'neutral', 'themeVariables': {'fontFamily': 'monospace', 'darkMode': true}}}%%

sequenceDiagram
    participant Shiny as Shiny App
    participant API as plumber2 API
    participant Log as logger
    participant File as shiny_app.log

    Shiny->>Log: INFO "Starting prediction request"
    Log->>File: write
    Shiny->>API: POST /predict
    API-->>Shiny: {".pred": [4180.797]}

    alt response_time <= 5s
        Shiny->>Log: INFO "Prediction successful" + response_time
        Log->>File: write
    else response_time > 5s
        Shiny->>Log: WARN "Slow API response"
        Log->>File: write
    else request errors
        Shiny->>Log: ERROR "Prediction request failed"
        Log->>File: write
    end

Response-Time Logging for a Prediction Request

Using from Shiny

From the Shiny side, both APIs look identical. The book uses this pattern to make requests:

library(shiny)
library(httr2)

ui <- fluidPage(
  numericInput("bill_length", "Bill length (mm)", 45),
  selectInput("species", "Species", c("Adelie", "Chinstrap", "Gentoo")),
  actionButton("predict", "Predict"),
  verbatimTextOutput("result")
)

server <- function(input, output, session) {
  pred <- eventReactive(input$predict, {
    req <- request("http://localhost:8000/predict") |>
      req_method("POST") |>
      req_body_json(list(
        bill_length_mm = input$bill_length,
        species = input$species,
        sex = "male"
      ))

    response <- req_perform(req)
    resp_body_json(response)
  })

  output$result <- renderPrint(pred())
}

shinyApp(ui, server)

Whether the API is built with plumber or plumber2, the client code stays the same.

Migration

After moving the existing plumber API to plumber2, here’s my mental model of the two packages:

%%{init: {'theme': 'base', 'themeVariables': {'fontFamily': 'monospace', 'darkMode': true}}}%%

flowchart TD
    Start(["Start: <code>plumber</code> API"])
    Step1["Replace <code>pr()</code><br/>with <code>api()</code>"]
    Step2["Change <code>#'</code> to <code>#*</code><br/>annotations"]
    Step3["Update handlers:<br/><code>req</code>,<code>res</code> → <code>body</code>,<code>query</code>"]
    Step4["Remove manual<br/>JSON parsing"]
    Step5["Update serializers<br/>& status codes"]
    Done(["Done: <code>plumber2</code> API"])

    Start --> Step1
    Step1 --> Step2
    Step2 --> Step3
    Step3 --> Step4
    Step4 --> Step5
    Step5 --> Done

    style Step1 fill:#E0F0ED,stroke:#4CBB9D,color:#000000,rx:5,ry:5
    style Step2 fill:#E0F0ED,stroke:#4CBB9D,color:#000000,rx:5,ry:5
    style Step3 fill:#E0F0ED,stroke:#4CBB9D,color:#000000,rx:5,ry:5
    style Step4 fill:#E0F0ED,stroke:#4CBB9D,color:#000000,rx:5,ry:5
    style Step5 fill:#E0F0ED,stroke:#4CBB9D,color:#000000,rx:5,ry:5
    style Start fill:#FFF8E7,stroke:#999,color:#000000
    style Done fill:#4CBB9D,stroke:#4CBB9D,color:#FFFFFF,rx:5,ry:5

Migration Steps: plumber to plumber2

The logic of the handlers stays the same; we’re updating the plumbing.

Why migrate?

plumber2 separates concerns better. Request and response objects are explicit, not hidden in function argument parsing. This makes middleware, error handling, and logging clearer. It also aligns with web framework conventions from other languages (Express.js, Flask), so patterns transfer between ecosystems.

You don’t have to migrate if your plumber API is working fine. But for new APIs, plumber2 is the recommended path.

Recap

Both plumber and plumber2 turn R into an API engine. The syntax and internal structure differ; the core idea remains: handlers respond to routes, wire into a router, serve responses. Logging works in both; plumber2 just makes request/response details more accessible.

The real value comes from integrating APIs with apps. A Shiny frontend, a Python dashboard, or a mobile client can all consume the same API. Build once, use everywhere.

For working examples and detailed guidance, see:

Footnotes

  1. Read a full description of the lab solutions for plumber and plumber2 in my DO4DS: Lab Solutions.↩︎

  2. The full application is in the R App Logging lab of my DO4DS: Lab Solutions.↩︎