skills/panel/SKILL.md
Best practices for developing tools, dashboards and interactive data apps with HoloViz Panel. Create reactive, component-based UIs with widgets, layouts, templates, and real-time updates. Use when developing interactive data exploration tools, dashboards, data apps, or any interactive Python web application. Supports file uploads, streaming data, multi-page apps, and integration with HoloViews, hvPlot, Pandas, Polars, DuckDB and the rest of the HoloViz and PyData ecosystems.
npx skillsauth add marcskovmadsen/holoviz-mcp panelInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
This document provides best practices for developing dashboards and data apps with HoloViz Panel in Python .py files.
Please develop as an Expert Python and Panel Developer developing advanced data-driven, analytics and testable dashboards and analytics apps would do. Keep the code short, concise, documented, testable and professional.
Core dependencies provided with the panel Python package:
Optional panel-extensions:
Optional dependencies from the HoloViz Ecosystem:
.plot()-like syntax. Best for: quick exploratory visualizations, interactive plots from DataFrames/Xarray, when you want interactivity without verbose code. Built on HoloViews.Optional dependencies from the wider PyData Ecosystem:
pip install panel watchfiles hvplot hvsampledata
For development in .py files DO always include watchfiles for hotreload.
Let's describe our best practices via a basic Hello World App:
# DO import panel as pn
import panel as pn
import param
# DO always run pn.extension
# DO remember to add any imports needed by panes, e.g. pn.extension("tabulator", "plotly", ...)
# DON'T add "bokeh" as an extension. It is not needed.
# Do use throttled=True when using slider unless you have a specific reason not to
pn.extension(throttled=True)
# DO organize functions to extract data separately as your app grows. Eventually in a separate data.py file.
# DO use caching to speed up the app, e.g. for expensive data loading or processing that would return the same result given same input arguments.
# DO add a ttl (time to live argument) for expensive data loading that changes over time
@pn.cache(max_items=3)
def extract(n=5):
return "Hello World" + "⭐" * n
text = extract()
text_len = len(text)
# DO organize functions to transform data separately as your app grows. Eventually in a separate transformations.py file
# DO add caching to speed up expensive data transformations
@pn.cache(max_items=3)
def transform(data: str, count: int=5)->str:
count = min(count, len(data))
return data[:count]
# DO organize functions to create plots separately as your app grows. Eventually in a separate plots.py file.
# DO organize custom components and views separately as your app grows. Eventually in separate components.py or views.py file(s).
# DO use param.Parameterized, pn.viewable.Viewer or similar approach to create new components and apps with state and reactivity
class HelloWorld(pn.viewable.Viewer):
# DO define parameters to hold state and drive the reactivity
characters = param.Integer(default=text_len, bounds=(0, text_len), doc="Number of characters to display")
def __init__(self, **params):
super().__init__(**params)
# DO use sizing_mode="stretch_width" for components unless "fixed" or other sizing_mode is specifically needed
with pn.config.set(sizing_mode="stretch_width"):
# DO create widgets using `.from_param` method
self._characters_input = pn.widgets.IntSlider.from_param(self.param.characters, margin=(10,20))
# DO Collect input widgets into horizontal, columnar layout unless other layout is specifically needed
self._inputs = pn.Column(self._characters_input, max_width=300)
# CRITICAL: Create panes ONCE with reactive content
# DON'T recreate panes and layouts in @param.depends methods - causes flickering!
# DO bind reactive methods/functions to panes for smooth updates
self._output_pane = pn.pane.Markdown(
self.model, # Reactive method reference
sizing_mode="stretch_width"
)
# DO collect output components into some layout like Column, Row, FlexBox or Grid depending on use case
self._outputs = pn.Column(self._output_pane)
# DO collect all of your components into a combined layout useful for displaying in notebooks etc.
self._panel = pn.Row(self._inputs, self._outputs)
# DO use caching to speed up bound methods that are expensive to compute or load data and return the same result for a given state of the class.
# DO add a ttl (time to live argument) for expensive data loading that changes over time.
@pn.cache(max_items=3)
# DO prefer .depends over .bind over .rx for reactivity methods on Parameterized classes as it can be typed and documented
# DON'T use `watch=True` or `.watch(...)` methods to update UI components directly.
# DO use `watch=True` or `.watch(...)` for updating the state parameters or triggering side effects like saving files or sending email.
@param.depends("characters")
def model(self):
# CRITICAL: Return ONLY the content, NOT the layout/pane
# The pane was created once in __init__, this just updates its content
return transform(text, self.characters)
# DO use `watch=True` or `.watch(...)` for updating the state parameters or triggering side effects like saving files or sending email.
@param.depends("characters", watch=True)
def _inform_user(self):
print(f"User selected to show {self.characters} characters.")
# DO provide a method for displaying the component in a notebook setting, i.e. without using a Template or other element that cannot be displayed in a notebook setting.
def __panel__(self):
return self._panel
# DO provide a method to create a .servable app
@classmethod
def create_app(cls, **params):
instance = cls(**params)
# DO use a Template or similar page layout for served apps
template = pn.template.FastListTemplate(
# DO provide a title for the app
title="Hello World App",
# DO provide optional image, optional app description, optional navigation menu, input widgets, optional documentation and optional links in the sidebar
# DO provide as list of components or a list of single horizontal layout like Column as the sidebar by default is 300 px wide
sidebar=[instance._inputs],
# DO provide a list of layouts and output components in the main area of the app.
# DO use Grid or FlexBox layouts for complex dashboard layouts instead of combination Rows and Columns.
main=[instance._outputs],
# DO set main_layout=None for modern layout
main_layout=None,
)
return template
# DON'T provide a `if __name__ == "__main__":` method to serve the app with `python`
# DO provide a method to serve the app with `panel serve`
if pn.state.served:
# Mark components to be displayed in the app with .servable()
HelloWorld.create_app().servable()
DO serve the app with
panel serve path_to_this_file.py --show --dev
DON'T serve with python path_to_this_file.py.
With panel you can easily create tests to test user behaviour without having to write client side tests.
DO always create separate tests in the tests folder:
# DO put tests in a separate test file.
# DO always test that the reactivity works as expected
def test_characters_reactivity():
"""
Test characters reactivity.
"""
# DO test the default values of bound
hello_world = HelloWorld()
# DO test the reactivity of bound methods when parameters change
assert hello_world.model() == text[:hello_world.characters]
hello_world.characters = 5
assert hello_world.model() == text[:5]
hello_world.characters = 3
assert hello_world.model() == text[:3]
DO run the tests with:
pytest tests/path/to/test_file.py
DO fix any errors identified.
param.Parameterized or pn.viewable.Viewer classes to organize and manage state.from_param() method. DON'T do this for panes, i.e. pn.pane.Str has no from_param method.@param.depends() for reactive methods@param.depends(..., watch=True) to update parameter/ state values and for side-effects like sending an email.Parameterized or Viewable classes# ❌ AVOID: Updating panes and other components directly. This makes it hard to reason about application flow and state
@param.depends('value', watch=True)
def update_plot(self):
self.output_pane.object = transform(text, self.characters)
The Golden Rule: Create layout structure ONCE, update content REACTIVELY
This pattern eliminates flickering and creates professional Panel applications:
# ✅ CORRECT: Create panes ONCE in __init__, bind reactive content
class Dashboard(pn.viewable.Viewer):
filter_value = param.String(default="all")
chart = param.Parameter()
def __init__(self, **params):
super().__init__(**params)
# 1. Create static panes with reactive content
self._summary_pane = pn.pane.Markdown(self._summary_text)
self._chart_pane = pn.pane.HoloViews(self.param.chart)
# 2. Create static layout structure
self._layout = pn.Column(
"# Dashboard", # Static title
self._summary_pane, # Reactive content
self._chart_pane, # Reactive content
)
# ✅ Good: Reactive content method
# Will be run multiple times when filter_value updates if multiple panes or reactive functions depend on the _summary_text method
@param.depends("filter_value")
def _summary_text(self):
# Returns string content only, NOT a pane
return f"**Count**: {len(self._get_data())}"
# ✅ Good: Reactive update of chart parameter
# Will be run only one time when filter_value updates - even if multiple panes or reactive functions depend on the chart value
@param.depends("filter_value", watch=True, on_init=True)
def _update_chart(self):
# updates the chart object only, NOT a pane
self.chart = self._get_data().hvplot.bar()
def __panel__(self):
return self._layout
# ❌ WRONG: Recreating layout in @param.depends - causes flickering!
class BadDashboard(pn.viewable.Viewer):
filter_value = param.String(default="all")
@param.depends("filter_value")
def view(self):
# DON'T recreate panes/layouts on every parameter change!
return pn.Column(
"# Dashboard",
pn.pane.Markdown(f"**Count**: {len(self._get_data())}"),
pn.pane.HoloViews(self._get_data().hvplot.bar()),
)
Why This Matters:
Key Rules:
__init__@param.depends for reactive methods that update pane contentUse
pn.widgets.IntSlider, pn.widgets.Select, pn.widgets.DateRangeSlider and other widgets for inputpn.widgets.Tabulator to display tabular data like DataFramesUse panes to display data:
pn.pane.Markdown and pn.pane.HTML to display stringspn.pane.HoloViews, pn.pane.Plotly, pn.pane.Matplotlib or pn.pane.ECharts to display plotsUse layouts to layout components:
pn.Column, pn.Row, pn.Tabs, pn.Accordion for layoutspn.template.FastListTemplate or other templates for served apps:template = pn.template.FastListTemplate(
title="Hello World App",
sidebar=[instance._inputs],
main=[instance._outputs],
main_layout=None,
)
sidebar, DO use the order: 1) optional logo, 2) description, 3) input widgets, 4) documentationsidebar, DO make sure components stretch width.main_layout=None for a modern styling.sizing_mode="stretch_width" by default:with pn.config.set(sizing_mode="stretch_width"):
character_input = pn.widgets...
output_pane = pn.pane....
FlexBox, GridSpec or GridBox for complex, responsive grid layoutsmin_width, min_height, max_width and max_height to prevent layout collapseDO remember to add extensions like "tabulator", "plotly" etc. to pn.extension to make sure their Javascript is loaded:
# ✅ Good
pn.extension("tabulator", "plotly")
DON'T add "bokeh". It's already loaded:
# ❌ Bad
pn.extension("bokeh")
DO make the main component .servable() to include it in the served app and use pn.state.served to run the main method when the app is panel serve'd.
# ✅ Correct:
if pn.state.served:
main().servable()
# ❌ Incorrect:
if __name__ == "__main__":
main().servable()
# ❌ Don't: Works, but not how we want to serve the app:
if __name__ == "__main__":
main().show()
pn.extension(defer_load=True, loading_indicator=True, ...)@pn.cache decorator for expensive computations@pn.io.profiler to identify bottlenecksIf you experience memory issues, make sure to:
pn.state.clear_caches()search (documentation), pn_search (find components), pn_get (component details), pn_params (parameter info), ref_get (reference guides), skill_get (best-practice skills).holoviz-mcp CLI is installed (also available as hv), use the equivalent CLI commands: holoviz-mcp search, holoviz-mcp pn search, holoviz-mcp pn get, holoviz-mcp pn params, holoviz-mcp ref get, holoviz-mcp skill get.Tabulator related information via https://panel.holoviz.org/search.html?q=Tabulator url.DO add tests to the tests folder and run them with pytest tests/path/to/test_file.py.
DO always start and keep running a development server panel serve path_to_file.py --dev --show with hot reload while developing!
--show flag, a browser tab will automatically open showing your app.--dev flag, the panel server and app will automatically reload if you change the code.--port {port-number} flag.--autoreload flagpython path_to_file.py to test or serve the app.# ✅ Good: Parameter-driven
widget = pn.widgets.Select.from_param(self.param.model_type, name="Model Type")
# ❌ Avoid: Manual management with links
widget = pn.widgets.Select(options=['A', 'B'], value='A')
widget.link(self, value='model_type') # Hard to reason about
# ✅ BEST: Static pane with reactive content (for classes)
class MyComponent(pn.viewable.Viewer):
value = param.Number(default=10)
def __init__(self, **params):
super().__init__(**params)
self._plot_pane = pn.pane.Matplotlib(self._create_plot)
@param.depends('value')
def _create_plot(self):
return create_plot(self.value) # Returns plot only, not pane
# ✅ GOOD: pn.bind for functions
slider = pn.widgets.IntSlider(value=10)
plot_pane = pn.pane.Matplotlib(pn.bind(create_plot, slider))
# ❌ AVOID: Recreating panes and other components directly. This causes flickering.
@param.depends('value')
def view(self):
return pn.pane.Matplotlib(create_plot(self.value)) # DON'T!
# ❌ AVOID: Updating panes and other components directly. This makes it hard to reason about application flow and state
@param.depends('value', watch=True)
def update_plot(self):
self.plot_pane.object = create_plot(self.value)
# DO: Create static layout with reactive content
def _get_kpi_card(self):
return pn.pane.HTML(
pn.Column(
"📊 Key Performance Metrics",
self.kpi_value # Reactive reference
),
styles={"padding": "20px", "border": "1px solid #ddd"},
sizing_mode="stretch_width"
)
@param.depends("characters")
def kpi_value(self):
return f"The kpi is {self.characters}"
CheckButtonGroup in a sidebar CheckButtonGroup(..., vertical=True).button_type="primary" and button_style="outline".Tabulator.disabled=True unless you would like the user to be able to edit the table.Markdown.disable_anchors=True to avoid page flickr when hovering over headers.pn.bind(some_func). Just run the function instead some_func().DO follow the relevant hvplot, holoviews and panel-holoviews skills/ best practice guides!
CRITICAL: On windows set non-interactive backend before importing matplotlib.pyplot:
Why: The 'agg' backend is required for server-side rendering without display. Panel needs to render plots as images, not interactive GUI windows.
Extension: DON'T include 'matplotlib' in pn.extension() - it doesn't require JavaScript loading like Plotly or Bokeh extensions.
# ✅ CORRECT
import matplotlib
matplotlib.use('agg')
import matplotlib.pyplot as plt
import panel as pn
pn.extension() # No 'matplotlib' needed
# ❌ WRONG
pn.extension('matplotlib') # Not a Panel extension
Best Practices:
plt.close(fig)Do set the template (theme) depending on the theme of the app.
def create_plot(self) -> go.Figure:
fig = ...
template = "plotly_dark" if pn.state.theme=="dark" else "plotly_white"
fig.update_layout(
template=template, # Change template to align with the theme
paper_bgcolor='rgba(0,0,0,0)', # Change to transparent background to align with the app background
plot_bgcolor='rgba(0,0,0,0)' # Change to transparent background to align with the app background
)
return fig
DO prefer ECharts dict configuration over of pyecharts CRITICAL: ECharts configurations must be JSON-serializable. Panel uses Bokeh's serialization mechanism which cannot serialize Python functions.
❌ NEVER use Python functions or lambdas in ECharts configuration:
# ❌ WRONG: Lambda functions cause SerializationError
option = {
"tooltip": {
"formatter": lambda params: f"Value: {params['value']}" # DON'T!
},
"xAxis": {
"axisLabel": {
"formatter": lambda value: f"{value}%" # DON'T!
}
},
"series": [{
"animationDelay": lambda idx: idx * 100 # DON'T!
}]
}
✅ DO use ECharts native string formatters or static values:
# ✅ CORRECT: Use ECharts template strings
option = {
"tooltip": {
"formatter": "{b}: {c}" # Template string
},
"xAxis": {
"axisLabel": {
"formatter": "{value}%" # Template string with formatting
}
},
"yAxis": {
"axisLabel": {
"formatter": "${value}" # Dollar sign prefix
}
},
"series": [{
"animationDelay": 100 # Static numeric value
}]
}
ECharts Formatter Template Syntax:
{a} - series name{b} - data name (category){c} - data value{d} - percentage (for pie charts){value} - axis value'{value}%', '${value}', '{value} units'If you need complex formatting logic, pre-process your data in Python before passing to ECharts rather than using formatters.
Reactive Updates with replaceMerge:
When updating ECharts dynamically (e.g., filtering data that changes the number of series), ECharts uses a merge strategy by default. This can cause old series to persist when series are removed.
✅ DO use replaceMerge option when series count can change:
# ✅ CORRECT: Use replaceMerge to fully replace series on updates
chart_pane = pn.pane.ECharts(
self._chart_config, # Reactive method or parameter
options={"replaceMerge": ["series"]}, # Replace series array instead of merging
sizing_mode="stretch_width",
height=400,
)
❌ WITHOUT replaceMerge - old series persist:
# ❌ WRONG: Old series remain when filtering reduces series count
chart_pane = pn.pane.ECharts(
self._chart_config,
sizing_mode="stretch_width",
)
# If config changes from 4 series to 2, ECharts merges and keeps all 4!
Use replaceMerge for any chart where:
DO make sure the chart title does not overlap with the rest of the plot including legend.
When comparing to date or time values to Pandas series convert to Timestamp:
start_date, end_date = self.date_range
# DO convert date objects to pandas Timestamp for proper comparison
start_date = pd.Timestamp(start_date)
end_date = pd.Timestamp(end_date)
filtered = filtered[
(filtered['date'] >= start_date) &
(filtered['date'] <= end_date)
]
development
Use when building Python classes with validated, typed parameters using the Param library. Triggers include creating configuration classes, building reusable components with state, implementing reactive dependencies between parameters, adding type-safe attributes with bounds/constraints, creating testable parameterized classes, or when users mention param.Parameterized, @param.depends, or param.watch.
tools
Best practices for developing modern looking tools, dashboards and data apps using HoloViz Panel and Panel Material UI components.
data-ai
Best practices for integrating HoloViews and hvPlot visualizations into Panel applications. Use when embedding HoloViews/hvPlot plots in Panel panes, preserving zoom/pan state across data refreshes with DynamicMap, composing DynamicMap overlays without type errors, using HoloViews streams (Selection1D, RangeXY, Tap, BoundsXY, Pipe, Buffer) with Panel, cross-filtering with link_selections, making HoloViews plots responsive in Panel layouts, or wiring Panel widgets to Bokeh plot properties with jslink.
development
Build custom Panel components using JSComponent (vanilla JS, web components), ReactComponent (React/JSX), AnyWidgetComponent (AnyWidget spec for cross-platform), or MaterialUIComponent (Material UI themed). Use when wrapping JS libraries, creating interactive widgets, or building themed components. Includes decision guide, best practices, DOs/DON'Ts, and Playwright UI testing patterns.