panel
Panel Development Skills
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.
Dependencies
Core dependencies provided with the panel Python package:
- panel: Core application framework
- param: A declarative approach to creating classes with typed, validated, and documented parameters. Fundamental to Panel's reactive programming model.
Optional panel-extensions:
- panel-material-ui: Modern Material UI components. To replace the panel native widgets within the next two years.
- panel-graphic-walker: Modern Tableau like interface. Can offload computations to the server and thus scale to large datasets.
Optional dependencies from the HoloViz Ecosystem:
- colorcet: Perceptually uniform colormaps collection. Best for: scientific visualization requiring accurate color representation, avoiding rainbow colormaps, accessible color schemes. Integrates with hvPlot, HoloViews, Matplotlib, Bokeh.
- datashader: Renders large datasets (millions+ points) into images for visualization. Best for: big data visualization, geospatial datasets, scatter plots with millions of points, heatmaps of dense data. Requires hvPlot or HoloViews as frontend.
- geoviews: Geographic data visualization with map projections and tile sources. Best for: geographic/geospatial plots, map-based dashboards, when you need coordinate systems and projections. Built on HoloViews, works seamlessly with hvPlot.
- holoviews: Declarative data visualization library with composable elements. Best for: complex multi-layered plots, advanced interactivity (linked brushing, selection), when you need fine control over plot composition, scientific visualizations. More powerful but steeper learning curve than hvPlot.
- holoviz-mcp: Model Context Protocol server for HoloViz ecosystem. Provides access to detailed documentation, component search and agent skills.
- hvplot: High-level plotting API with Pandas
.plot()-like syntax. Best for: quick exploratory visualizations, interactive plots from DataFrames/Xarray, when you want interactivity without verbose code. Built on HoloViews. - hvsampledata: Shared datasets for the HoloViz projects.
Optional dependencies from the wider PyData Ecosystem:
- altair: Declarative, grammar-of-graphics visualization library. Best for: statistical visualizations, interactive exploratory charts, when you need Vega-Lite's extensive chart gallery. Works well with Pandas/Polars DataFrames.
- dask: Parallel computing library for scaling Pandas DataFrames beyond memory. Best for: processing datasets larger than RAM, parallel computation across multiple cores/machines, lazy evaluation workflows.
- duckdb: High-performance analytical SQL database. Best for: fast SQL queries on DataFrames, aggregations on large datasets, when you need SQL interface, OLAP-style analytics. Much faster than Pandas for analytical queries.
- matplotlib: Low-level, highly customizable plotting library. Best for: publication-quality static plots, fine-grained control over every aspect of visualization, scientific plots, when you need pixel-perfect control.
- pandas: Industry-standard DataFrame library for tabular data. Best for: data cleaning, transformation, time series analysis, datasets that fit in memory. The default choice for most data work.
- Plotly: Interactive, publication-quality visualization library. Best for: 3D plots, complex interactive charts, animations, when you need hover tooltips and interactivity. Works well with Dash and Panel.
- polars: Modern, fast DataFrame library written in Rust. Best for: high-performance data processing, datasets that fit in memory but need speed, when you need lazy evaluation, better memory efficiency than Pandas.
- xarray: N-dimensional labeled arrays and datasets. Best for: multidimensional scientific data (climate, satellite imagery), data with multiple dimensions and coordinates, NetCDF/HDF5 files, geospatial raster data.
- watchfiles: Enables high performance file watching and autoreload for the panel server.
Common Use Cases
- Real-time Monitoring Dashboards: Live metrics and KPI displays
- Data Exploration Tools: Interactive data analysis applications
- Configuration Interfaces: Complex multi-step configuration UIs
- Data Input Applications: Validated form-based data collection
- Report Viewers: Interactive report generation and browsing
- Administrative Interfaces: Internal tools for data management
Installation
pip install panel watchfiles hvplot hvsampledata
For development in .py files DO always include watchfiles for hotreload.
Best Practice Hello World App
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.
Best Practice Hello World Tests
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.
Key Patterns
Parameter-Driven Architecture
- DO use
param.Parameterizedorpn.viewable.Viewerclasses to organize and manage state - DO create widgets with
.from_param()method. DON'T do this for panes, i.e. pn.pane.Str has no from_param method. - DO use
@param.depends()for reactive methods - DO use
@param.depends(..., watch=True)to update parameter/ state values and for side-effects like sending an email. - DO group related parameters in separate
ParameterizedorViewableclasses
# ❌ 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)
Create Static Layout with Reactive Content (CRITICAL)
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:
- ✅ Smooth updates without layout reconstruction
- ✅ No flickering - seamless transitions
- ✅ Better performance - avoids unnecessary DOM updates
- ✅ Professional UX
Key Rules:
- DO create main layout structure and panes ONCE in
__init__ - DO bind panes to reactive methods or parameters (DON'T recreate them)
- Reactive methods should return CONTENT only (strings, plots, dataframes), NOT panes/layouts
- Use
@param.dependsfor reactive methods that update pane content
Widgets
Use
pn.widgets.IntSlider,pn.widgets.Select,pn.widgets.DateRangeSliderand other widgets for inputpn.widgets.Tabulatorto display tabular data like DataFrames
Panes
Use panes to display data:
pn.pane.Markdownandpn.pane.HTMLto display stringspn.pane.HoloViews,pn.pane.Plotly,pn.pane.Matplotliborpn.pane.EChartsto display plots
Layouts
Use layouts to layout components:
- DO use
pn.Column,pn.Row,pn.Tabs,pn.Accordionfor layouts
Templates
- DO use
pn.template.FastListTemplateor other templates for served apps:
template = pn.template.FastListTemplate(
title="Hello World App",
sidebar=[instance._inputs],
main=[instance._outputs],
main_layout=None,
)
- In the
sidebar, DO use the order: 1) optional logo, 2) description, 3) input widgets, 4) documentation - In the
sidebar, DO make sure components stretch width. - Do set
main_layout=Nonefor a modern styling.
Responsive Design
- DO use
sizing_mode="stretch_width"by default:
with pn.config.set(sizing_mode="stretch_width"):
character_input = pn.widgets...
output_pane = pn.pane....
- DO use
FlexBox,GridSpecorGridBoxfor complex, responsive grid layouts - DO set appropriate
min_width,min_height,max_widthandmax_heightto prevent layout collapse
Extensions
DO 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")
Servable()
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()
Performance Optimization
- Defer load: Defer load to after the app is shown to the user:
pn.extension(defer_load=True, loading_indicator=True, ...) - Lazy-load components using Tabs or Accordion for heavy content
- Use caching with
@pn.cachedecorator for expensive computations - Use async/await: Implement asynchronous patterns for I/O operations
- Use faster frameworks: Replace slower Pandas with faster Polars or DuckDB
- Offload to threads: Consider using threading for CPU-intensive tasks
- Offload to external processes: Consider offloading heavy computations to external processes like databases, scheduled (airflow) jobs, REST apis etc.
- Profile callbacks with
@pn.io.profilerto identify bottlenecks
If you experience memory issues, make sure to:
- Limit history: Cap data history sizes in streaming applications
- Clear caches: Periodically call
pn.state.clear_caches() - Restart periodically: Schedule application restarts for long-running production apps
- Profile memory: Use memory profilers (memory_profiler, tracemalloc) to identify leaks
Code Organization
- Separate concerns: Keep UI code separate from business logic using Param classes
- Create reusable components: Extract common patterns into functions or classes
- Use templates for consistent application structure across pages
- Organize modules: Group related components and utilities
- Document parameters: Add clear docstrings to Parameterized classes
Workflows
Lookup additional information
- If the HoloViz MCP server tools are available, DO use them to look up documentation and component details. Useful tools:
search(documentation),pn_search(find components),pn_get(component details),pn_params(parameter info),ref_get(reference guides),skill_get(best-practice skills). - If MCP tools are not available but the
holoviz-mcpCLI is installed (also available ashv), 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. - If neither is available, DO search the web. For example searching the Panel website for
Tabulatorrelated information via https://panel.holoviz.org/search.html?q=Tabulator url.
Test the app with pytest
DO add tests to the tests folder and run them with pytest tests/path/to/test_file.py.
- DO structure your code with Parameterized components, so that reactivity and user interactions can be tested easily.
- DO separate UI logic from business logic to enable unit testing
- DO separate data extraction, data transformation, plots generation, custom components and views, styles etc. to enable unit testing as your app grows
- DO fix any test errors and rerun the tests
- DO run the tests and fix errors before serving the app and asking the user to run manual tests
Test the app manually with panel serve
DO always start and keep running a development server panel serve path_to_file.py --dev --show with hot reload while developing!
- Due to
--showflag, a browser tab will automatically open showing your app. - Due to
--devflag, the panel server and app will automatically reload if you change the code. - The app will be served at http://localhost:5006/.
- DO make sure the correct virtual environment is activated before serving the app.
- DO fix any errors that show up in the terminal. Consider adding new tests to ensure they don't happen again.
- DON'T stop or restart the server after changing the code. The app will automatically reload.
- If you see 'Cannot start Bokeh server, port 5006 is already in use' in the terminal, DO serve the app on another port with
--port {port-number}flag. - DO remind the user to test the application on multiple screen sizes (desktop, tablet, mobile)
- DON'T use legacy
--autoreloadflag - DON't run
python path_to_file.pyto test or serve the app. - If you close the server to run other commands DO remember to restart it.
Quick Reference
Widget Creation
# ✅ 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
Reactive Updates Pattern
# ✅ 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)
Static Components Pattern
# 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}"
Other Guidance
CheckButtonGroup
- DO arrange vertically when displaying
CheckButtonGroupin a sidebarCheckButtonGroup(..., vertical=True). - DO set
button_type="primary"andbutton_style="outline".
Tabulator
- DO set
Tabulator.disabled=Trueunless you would like the user to be able to edit the table. - DO prefer Tabulator Formatters over Bokeh formatters and Pandas Styling.
- DO prefer Tabulator Editors over Bokeh Editor types
Markdown
- DO set
Markdown.disable_anchors=Trueto avoid page flickr when hovering over headers.
Bind
- DON't bind a function to nothing:
pn.bind(some_func). Just run the function insteadsome_func().
Plotting
- DO use bar charts over pie Charts.
HoloViews/hvPlot
DO follow the relevant hvplot, holoviews and panel-holoviews skills/ best practice guides!
Matplotlib
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:
- DO use matplotlib for publication-quality static plots
- DO close figures after rendering to prevent memory leaks:
plt.close(fig) - DO consider hvPlot or Plotly for interactive plots instead
Plotly
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
ECharts
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- Supports prefix/suffix:
'{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:
- Users can filter data (year selectors, category filters)
- The number of series changes dynamically
- Data is grouped by categories that may be added/removed
DO make sure the chart title does not overlap with the rest of the plot including legend.
Date time widgets
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)
]