plumber vs plumber2

What changed in the Plumber API framework

APIs
Shiny
Author

Martin Frigaard

Published

December 17, 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 is 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:

  • Path parameters: named function arguments (e.g., /users/<id>function(id))
  • Query parameters: accessed via the query argument
  • Request body: accessed via the body argument (already parsed JSON)
  • Response control: use 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 book’s 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 book’s 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 you to parse req$postBody manually; plumber2 provides body already parsed. For everything else, the logic is identical.

Logging

Both versions integrate seamlessly with the logger package for observability.

plumber logging

library(plumber)
library(logger)

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

plumber2 logging

library(plumber2)
library(logger)

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

The logging code is nearly identical in both versions. The real advantage in plumber2 comes when you log request details. Because body and response are explicit arguments, you have direct access to request metadata:

#* @post /predict
#* @serializer json
handle_predict <- function(body, response) {
  log_info("Prediction request received")

  tryCatch({
    pred_data <- prep_pred_data(body)
    prediction <- predict(v, pred_data)
    log_info("Prediction successful: {prediction}")
    list(.pred = as.numeric(prediction))
  },
  error = function(e) {
    log_error("Prediction failed: {e$message}")
    response$status <- 400L
    list(error = e$message)
  })
}

Logging captures the full lifecycle of a request:

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

sequenceDiagram
    participant Shiny as Shiny App
    participant API as Plumber API
    participant Handler as Handler
    participant Log as Logger
    participant File as Log File

    Shiny->>API: POST /predict
    API->>Log: log_info("Request received")
    Log->>File: write
    API->>Handler: dispatch
    Handler->>Log: log_info("Processing...")
    Log->>File: write
    Handler->>API: result
    API->>Log: log_info("Response sent")
    Log->>File: write
    API->>Shiny: JSON response
    

Logging Flow in plumber2

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 your API is built with plumber or plumber2, the client code stays the same.

Migration

If you’re moving an existing plumber API to plumber2, here’s the mental model:

%%{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 your handlers stays the same; you’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.↩︎