tune finalize_workflow() in R: Lock In Best Hyperparameters
The tune finalize_workflow() function in R takes a workflow that still contains tune() placeholders and substitutes them with the winning parameter values returned by select_best(). The output is a regular workflow ready for last_fit() or a final fit() on the full training data.
finalize_workflow(wf, best_params) # standard finalization finalize_workflow(wf, select_best(tune_res, "rmse")) # inline pick + finalize finalize_workflow(wf, tibble(mtry = 4, trees = 500)) # manual parameter row finalize_workflow(wf, best_params) |> last_fit(split) # finalize and last_fit finalize_workflow(wf, best_params) |> fit(data = train) # finalize and full fit finalize_model(spec, best_params) # bare spec sibling
Need explanation? Read on for examples and pitfalls.
What finalize_workflow() does in one sentence
finalize_workflow() swaps every tune() placeholder for a concrete value. Hand it a workflow that was built with tune() markers on model or recipe arguments, plus a one-row tibble of parameter values from select_best(), and you get back the same workflow with those tune() markers replaced by the picked numbers. The returned workflow is fully specified, so fit() and last_fit() accept it without any further setup.
The function does not retrain or score anything by itself. It is a parameter substitution step that sits between tuning and the final fit, separating model selection from model training.
finalize_workflow() is the bridge that turns the winning row into a workflow you can train. Skip this step and last_fit() errors because the workflow still contains unresolved tune() calls.finalize_workflow() syntax and arguments
finalize_workflow() takes exactly two arguments and returns a workflow. Both arguments are positional and required.
The x argument is the tunable workflow you originally passed to tune_grid() or tune_bayes(). The parameters argument is a single-row tibble whose column names match the tunable arguments inside the workflow. Calling select_best() returns exactly this shape, so the two functions chain naturally.
For a bare model spec (no preprocessor wrapped in a workflow), reach for finalize_model(). For a bare recipe (no model), use finalize_recipe(). The call shape is identical; only the target object changes.
Use finalize_workflow() in four scenarios
Every example below runs on built-in R data so the tuning, selection, and finalization chain reproduces in one session. A small random forest on mtcars keeps each tune fast.
Example 1: Finalize a random forest workflow with select_best()
Tune mtry and trees, pick the best row, then finalize the workflow. The result is a workflow with concrete numbers instead of tune() placeholders.
Notice how the model block now shows mtry = 4 and trees = 500 instead of mtry = tune() and trees = tune(). The workflow is ready to fit. finalize_workflow() reads the column names of best_rf, finds matching tune() markers inside the workflow, and substitutes them in place; the .config column from select_best() is silently ignored.
Example 2: Finalize and run last_fit() in one chain
Pair finalize_workflow() with last_fit() to train on the full training set and score the test split. This is the canonical end-of-tuning pattern.
last_fit() consumes the finalized workflow, trains it on the training split, and scores the test split. Calling collect_metrics() on the result returns the held-out test metrics, the number you report. The pipe-chained form makes the intent explicit: take the tunable workflow, substitute the winning parameters, then train and score on the test split in one step.
Example 3: Finalize with a manually constructed parameter tibble
You can hand finalize_workflow() any one-row tibble whose columns match the tunable arguments. This is useful when you want to refit at a specific operating point rather than the tuner's pick.
The column names of my_params must match the tunable arguments exactly. Extra columns are ignored; missing columns cause an error. This manual path is the same one select_best() walks under the hood: it constructs a tibble with the right shape and hands it to finalize_workflow(). The manual route is useful when you want to refit at a runner-up combination, reproduce a model from a saved config file, or sanity-check the pipeline at a known-good point.
Example 4: Finalize a classification workflow
The call shape is identical for classification. Switch the model and metric; everything else stays the same.
The pattern reads identically: tune, select, finalize. Only the model spec and the metric change between regression and classification problems. The substitution mechanics inside finalize_workflow() are agnostic to the model family; the same chain extends unchanged to survival and censored-regression models.
final_wf around lets you refit on new data, inspect the resolved arguments with extract_spec_parsnip(), or serialize the recipe alongside the model without re-running the tuner.Compare finalize_workflow() with finalize_model() and finalize_recipe()
The three finalize verbs do the same job on different objects. Pick the one whose input shape matches what you tuned.
| Function | Operates on | Returns | Use when |
|---|---|---|---|
finalize_workflow() |
a workflow with tune() markers |
finalized workflow | Tuning included model and recipe steps |
finalize_model() |
a bare model spec | finalized spec | Tuning only model hyperparameters |
finalize_recipe() |
a bare recipe | finalized recipe | Tuning only preprocessing parameters |
finalize_workflow() (with manual tibble) |
any workflow | finalized workflow | You want to refit at a chosen point |
Reach for finalize_workflow() first; it is the only verb that handles both a recipe and a model together. When in doubt, follow the shape of the object you tuned: a workflow goes to finalize_workflow(), a bare model spec to finalize_model(), a bare recipe to finalize_recipe(). Mixing them returns an error pointing at the mismatch, so the wrong choice surfaces immediately.
Common pitfalls
Three finalize_workflow() mistakes account for most stuck pipelines. Each shows the symptom and the fix.
The first is passing a show_best() result instead of a select_best() result. show_best() returns the parameters plus mean, std_err, n, and .config. The extra columns confuse finalize_workflow(), which expects only parameter columns and .config.
The second is forgetting the tune() markers on the workflow. If the workflow was built without tune() placeholders, there is nothing to finalize, and the call errors with no tunable parameters. The third is feeding parameters with wrong column names, often from a manual tibble. The column names must match the tunable arguments exactly, case included.
trees = -5 finalizes silently; the error surfaces later when the underlying engine refuses the value. Always sanity-check the printed workflow before fitting.Try it yourself
Try it: Tune a decision_tree() spec on mtcars over tree_depth values 3, 5, and 7, pick the best row, then finalize the workflow. Save the finalized workflow to ex_final_wf.
Click to reveal solution
Explanation: select_best() returns a one-row tibble with the winning tree_depth. Passing it to finalize_workflow() substitutes that value into the workflow, replacing the tune() placeholder so fit() or last_fit() can run.
Related tune and workflows functions
finalize_workflow() sits between selection and final fit. These siblings cover the steps on either side.
select_best()returns the one-row parameter tibble that feedsfinalize_workflow().finalize_model()does the same substitution on a bare model spec.finalize_recipe()does the same substitution on a bare recipe.last_fit()trains the finalized workflow on the training split and scores the test split.fit()trains the finalized workflow on whatever data you pass.extract_spec_parsnip()pulls the resolved model spec out of the finalized workflow.
FAQ
What is the difference between finalize_workflow() and finalize_model()?
finalize_workflow() operates on a workflow that wraps a recipe and a model together; finalize_model() operates on a bare model spec without a workflow. The call shape is identical and both substitute tune() markers with values from a one-row parameter tibble. Choose finalize_workflow() when your tuning target includes a recipe step. Choose finalize_model() when you tune only the model hyperparameters in isolation.
Does finalize_workflow() refit the model?
No. finalize_workflow() only substitutes the tuned parameter values into the workflow; it does not fit anything. To train the finalized workflow, pass the result to fit(final_wf, data = train) or to last_fit(final_wf, split) for the combined train-and-score pattern. Skipping the fit step leaves a workflow with parameters but no trained model.
Can I pass a manually constructed tibble to finalize_workflow()?
Yes. Any one-row tibble whose column names match the tunable arguments works. This is useful for refitting at a runner-up combination, a value loaded from a config file, or a known-good point during debugging. The column names must match the tune() arguments exactly; extra columns are ignored, missing columns cause an error. The reference docs at tune.tidymodels.org describe the contract.
What if my workflow has no tune() markers at all?
finalize_workflow() errors because there is nothing to substitute. The workflow is already concrete and you can call fit() on it directly. Use finalize_workflow() only when at least one model or recipe argument is wrapped in tune(). To check whether a workflow has tunable parameters, call extract_parameter_set_dials(wf) and look for non-empty output.