Param¶
Skill version 0.1.0
Correct patterns and common pitfalls for Param — the reactive parameter library that underpins Panel, HoloViews, and the HoloViz ecosystem.
Contents¶
- Lookup — site search
- Parameterized Classes
- Reactive Dependencies (@param.depends)
- Dependent Parameters
- Parameter Types
- Reactive Expressions (rx)
- .watch() vs @param.depends vs .link()
- allow_refs
- Custom Parameter Types
Lookup¶
Web-search https://param.holoviz.org/search.html?q=<topic> for anything not covered below;
the Reactive Expressions guide
is the deepest reference for rx.
Parameterized Classes¶
- Add
# pyright: reportAssignmentType=falseat the top — Param's descriptors conflict with static type checkers. - Add type annotations (
target: str = param.String(...)) for IDE autocomplete — Param doesn't enforce them at runtime. - Never use
nameas a parameter. Param won't stop you, butnameisconstant— it can only be set in the constructor, andobj.name = "x"afterwards raisesTypeError: Constant parameter 'name' cannot be modified. You also lose the auto-generated unique instance name (DataConfig00042) that Param uses in reprs and error messages. - Never name a param in UPPERCASE_SNAKE_CASE. A param is reactive, settable, watchable instance state, so an all-caps name misreads as a frozen module constant —
self.MAX_ROWS = 50looks illegal and isn't. It also leaks into UIs: Param's default label formatter only upper-cases the first character (pname[:1].upper() + pname[1:]) rather than title-casing, soMAX_ROWSbecomes the shouty widget label "MAX ROWS" whilemax_rowsbecomes "Max rows". Uselowercase_snake_casefor params and keep genuine constants at module level. self.param.param_keyis the Parameter object;self.param_keyis the current value. Useself.param.param_keywith.from_param()and pane constructors.
# pyright: reportAssignmentType=false
import param
class DataConfig(param.Parameterized):
source: str = param.Selector(default="CSV", objects=["CSV", "Parquet", "SQL"], doc="Data source type")
limit: int = param.Integer(default=1000, bounds=(1, 100_000), doc="Max rows to load")
filters: list = param.List(default=[], item_type=str, doc="Column filters to apply")
Reactive Dependencies (@param.depends)¶
- Without
watch=True: lazy, called only when something reads the result. Returns content. Withwatch=True: eager, fires every time the parameter changes. Use for side effects only. - Don't use
watch=Trueto update UI — causes flickering (thepanelskill covers this). on_init=Trueruns the method once at instantiation. Use withwatch=Trueto set initial state.- A method without
watch=Truemay run multiple times if multiple panes depend on it. Usewatch=Trueto update a parameter instead, then bind panes to that parameter.
import param
class Analysis(param.Parameterized):
query: str = param.String(default="SELECT *")
result = param.DataFrame()
@param.depends("result")
def summary(self):
if self.result is None:
return "No data loaded."
return f"**{len(self.result)} rows**, {len(self.result.columns)} columns"
@param.depends("query", watch=True, on_init=True)
def _run_query(self):
self.result = execute_query(self.query)
Dependent Parameters¶
- When updating
.objects, always check if the current value is still valid — reset it if not.
import param
class CountrySelector(param.Parameterized):
_countries = {
"Europe": ["France", "Germany", "Spain"],
"Asia": ["China", "Japan", "India"],
}
continent: str = param.Selector(default="Europe", objects=["Europe", "Asia"])
country: str = param.Selector(default="France", objects=["France", "Germany", "Spain"])
@param.depends("continent", watch=True, on_init=True)
def _update_countries(self):
countries = self._countries[self.continent]
self.param.country.objects = countries
if self.country not in countries:
self.country = countries[0]
Parameter Types¶
obj.param.update(**kwargs)applies multiple changes atomically on one Parameterized object — a Param class, or any Panel widget/pane, since those are Parameterized too. A watcher on both params fires once with two events, not twice, so write the callback asdef cb(*events).obj.param.update()used as a context manager sets values temporarily and restores the originals on exit —with slider.param.update(value=9): ...leavesvalueback at its previous value afterwards, and the restore fires watchers a second time. It is a scoped override, not a way to batch assignments written inside the block; those still notify one at a time. Use the keyword form above to batch.pn.io.hold()is the Panel-side complement and a different layer: it holds events on the Bokeh Document so the browser receives one combined update instead of several. It does not affect Param watcher dispatch — use it to stop UI flicker while mutating several components, and.param.update()to batch Param events. Works as a context manager or a decorator.- Use the most specific type (
param.IntegernotNumber,param.SelectornotString). Specificity drives widget selection in Panel's.from_param(). softboundssuggests a range for UI sliders without hard enforcement.stephints the increment.labeloverrides the display name.precedencecontrols ordering (lower = first).param.List(item_type=str)validates contents.param.Dictdoes not validate values.param.DataFrame()accepts pandas only. For Polars, useparam.Parameter().param.Event()resets toFalseafter firing watchers. Use withButton.from_param()+@param.depends(watch=True)when you need a declarative trigger.- Single source of truth: For navigation buttons (back/next), have handlers modify one shared parameter (e.g.,
active_step), then watch that parameter once. All UI state derives from one place. - Reassign, don't mutate: In-place operations (
+=,list.append(),dict.update()) don't trigger watchers — mutating the object the parameter points at is never an attribute assignment, so Param's descriptor never runs and no event is emitted. (It is not that Param compares by identity; when you do assign, it compares by value.) Always reassign:self.x = self.x + 1,self.items = self.items + [new],self.data = {**self.data, key: val}. default_factoryfor dynamic per-instance defaults (a fresh UUID, a timestamp). It overridesdefaultif both are given. Note thatparam.Listandparam.Dictalready default toinstantiate=True, soparam.List(default=[])is not shared between instances — the sharing hazard is real only forparam.Parameter(default=[])and other types withinstantiate=False, whereinstantiate=Trueis the fix.- Param does not auto-coerce types (unlike Pydantic). Assigning
obj.limit = "25"to aparam.Integerattribute raisesValueError.
import uuid
import param
import panel as pn
class TrackedItem(param.Parameterized):
id: str = param.String(default_factory=lambda: str(uuid.uuid4()))
tags: list = param.List(default=[]) # List already instantiates per instance
scratch = param.Parameter(default=[], instantiate=True) # needed here, not above
temperature: float = param.Number(
default=0.7, bounds=(0, 2), softbounds=(0, 1),
step=0.1, label="LLM Temperature", precedence=1,
)
submit: bool = param.Event(doc="Trigger processing")
config = DataConfig()
# ✅ Batch Param events — one watcher call receiving two events
config.param.update(source="Parquet", limit=500)
# Same method on a Panel widget, because widgets are Parameterized
slider = pn.widgets.IntSlider(value=1, start=0, end=10)
slider.param.update(value=5, end=20)
# ⚠️ The context-manager form is a scoped override that RESTORES on exit
with slider.param.update(value=9):
... # slider.value == 9 in here
# slider.value is back to 5, and the restore fires watchers again
# Panel-side: one combined update to the browser, no flicker.
# Orthogonal to the above — this does not batch Param watcher calls.
with pn.io.hold():
slider.value = 7
slider.end = 30
For the param.Event + Button.from_param() + @param.depends(watch=True) button pattern, see the declarative example under .watch() vs @param.depends vs .link().
Reactive Expressions (rx)¶
param.rx() / .rx() creates reactive expressions that automatically update when dependencies change. A lambda is to a Python function as rx is to pn.bind. Use rx instead of lambdas and pn.bind for cleaner, more declarative code.
import panel as pn
select = pn.widgets.Select(options=["a", "b", "c"])
# ✅ Concise rx — replaces verbose pn.bind callback
button = pn.widgets.Button(name="Add " + select.param.value.rx())
# ❌ Verbose pn.bind equivalent
button = pn.widgets.Button(name=pn.bind(lambda x: f"Add {x}", select))
Core Operations¶
# Chain operations — indexing, slicing, methods all work
step = select.param.value.rx() # reactive ref to the selected value
# Conditional with rx.where (replaces if/else lambdas)
bar_color = toggle.param.value.rx.where("success", "warning")
# Boolean operations
visible = items.param.value.rx.len() > 0
hidden = toggle.param.value.rx.not_()
# Transform with rx.pipe — passes value as first arg
formatted = value.rx.pipe(format_func, extra_arg)
# Side effects with rx.watch (use sparingly)
expr.rx.watch(callback_func) # callback receives the value, not an event
Syncing Parameters¶
For syncing widget parameters to class parameters, use pn.bind(..., watch=True):
class Wizard(pn.viewable.Viewer):
active_step = param.Integer(default=0)
def __init__(self, **params):
super().__init__(**params)
self._menu = pn.widgets.RadioButtonGroup(options=["Step 1", "Step 2", "Step 3"])
pn.bind(self._on_menu_select, self._menu.param.value, watch=True)
def _on_menu_select(self, value):
new_step = self._menu.options.index(value)
if new_step != self.active_step:
self.active_step = new_step
Avoid allow_refs=True with rx binding (self.x = widget.param.value.rx()) when you also need to directly assign to that parameter — the binding breaks on direct assignment.
Gotchas¶
- Update via namespace:
expr.rx.value = new_value— direct assignmentexpr = new_valuebreaks reactivity - Accessor vs method:
.rx.valuegets current value,.rx()returns reactive expression for chaining - String concat:
"prefix " + widget.param.value.rx()works; for complex cases wrap literals withpn.rx("text") - Dict access for conflicts: Use
obj.param["objects"].rx.len()when parameter name conflicts with rx methods
.watch() vs @param.depends vs .link()¶
Reactive wiring, most to least preferred:
@param.depends("p1", "p2")(nowatch) — pure; returns content for display, no side effects. Use whenever a value or pane is derived from params.@param.depends("p", watch=True)— a side effect (update another param, sync state) driven by the object's own params. Declarative, noeventargument.pn.bind(fn, obj.param.x, watch=True)— a side effect driven by another instance's param (e.g. a widget) where@param.dependscan't reach. Still declarative;fnreceives the value..param.watch(fn, "x")— last resort: when you need.old/.new(logging, undo), or must wire/unwire watchers conditionally at runtime.
watch=True in any form is for side effects only — never to produce content for display (that's option 1).
For syncing parameters between objects, use .link() or .rx():
# Direct property sync — no callback needed
text_input.link(markdown_pane, value='object')
# Bind parameter to rx expression (requires allow_refs=True)
class Wizard(param.Parameterized):
active_step = param.Integer(default=0, allow_refs=True)
def __init__(self, **params):
super().__init__(**params)
self._menu = pn.widgets.RadioButtonGroup(options=["Step 1", "Step 2", "Step 3"])
self.active_step = self._menu.param.value.rx.pipe(self._menu.options.index) # menu -> step sync
For inline reactive string formatting, prefer .rx() over a pn.bind/lambda callback — see Reactive Expressions (rx).
# ✅ Preferred — declarative, no event argument
class Wizard(param.Parameterized):
go_next = param.Event()
step = param.Integer(default=0)
@param.depends("go_next", watch=True)
def _on_go_next(self):
self.step = self.step + 1
# ❌ Avoid — imperative, requires event argument
def on_change(event):
print(f"{event.name}: {event.old} → {event.new}")
config.param.watch(on_change, ["source", "limit"])
allow_refs¶
allow_refs=True lets a parameter track another Parameter object, staying in sync automatically.
import param
class Source(param.Parameterized):
value: int = param.Integer(default=10)
class Consumer(param.Parameterized):
input_value: int = param.Integer(default=0, allow_refs=True)
source = Source()
consumer = Consumer(input_value=source.param.value)
source.value = 20
print(consumer.input_value) # 20
Custom Parameter Types¶
Subclass and override _validate_value. Always call super()._validate_value() first.