Building a Custom Filter or Control-Panel Component for Apache Superset

Two different things get called a “Superset custom filter”, and they are built in completely different places.The first is a control: a widget in the Explore control panel that configures a single chart. Metrics, group-by, colour scheme, a threshold slider. Controls belong to the chart plugin and are declared in controlPanel.ts.

The second is a native filter: a widget in the dashboard filter bar that pushes a value into every chart in scope. Select, range, time range, time column. Native filters belong to the dashboard, not to any chart, and are implemented inside the Superset frontend.

There is a third thing that sits between them: a chart that acts as a filter, so that clicking a bar or a region filters the rest of the dashboard. That is cross-filtering, and it is something a plugin can do on its own.

This guide covers all three, in order of how much of Superset you have to touch. Most requirements are satisfied by the first, which needs no changes to Superset at all.

Part 1: Custom controls inside a plugin

A control panel is data, not code: a ControlPanelConfig object listing sections, each with rows of controls. Each control has a type naming a React component that Superset already ships, plus configuration. You can go a long way by composing those components well.

The control types you already have

@superset-ui/chart-controls exposes the components Superset’s own charts use. The ones that cover most needs:

type What it renders Typical use
SelectControl Single or multi select, optionally free-form Pick a column, a mode, an aggregation
TextControl Text or number input Thresholds, labels, limits
TextAreaControl Multi-line text, optional code highlighting Custom SQL, JSON, templates
CheckboxControl Boolean toggle Show legend, stack bars
SliderControl Numeric slider Opacity, radius, bucket count
RadioButtonControl Mutually exclusive options Chart orientation, sort order
ColorPickerControl Single colour Highlight colour
ColorSchemeControl Registered categorical scheme Series colours
BoundsControl Min and max pair Axis bounds
MetricsControl Saved or ad-hoc metrics Any measure
AdhocFilterControl Filter builder Where clauses
CollectionControl Repeating group of sub-controls Multiple thresholds, multiple series

You reference them by string in the type field. The shared, pre-configured versions for metrics, group-by, filters and row limits live in sharedControls; spread one and override the label and description rather than configuring from scratch.

Dynamic visibility

Controls can appear and disappear based on other controls. The visibility function receives the current control state and returns a boolean:

typescript
[
  {
    name: 'threshold_mode',
    config: {
      type: 'RadioButtonControl',
      label: t('Threshold'),
      default: 'none',
      options: [
        ['none', t('None')],
        ['fixed', t('Fixed value')],
        ['percentile', t('Percentile')],
      ],
      renderTrigger: true,
    },
  },
],
[
  {
    name: 'threshold_value',
    config: {
      type: 'TextControl',
      label: t('Threshold value'),
      isFloat: true,
      default: 0,
      renderTrigger: true,
      visibility: ({ controls }) => controls?.threshold_mode?.value === 'fixed',
    },
  },
],
[
  {
    name: 'threshold_percentile',
    config: {
      type: 'SliderControl',
      label: t('Percentile'),
      min: 1,
      max: 99,
      default: 90,
      renderTrigger: true,
      visibility: ({ controls }) => controls?.threshold_mode?.value === 'percentile',
    },
  },
],

This pattern replaces a great deal of what people initially think needs a custom component. A control panel that shows only the relevant options feels bespoke without being bespoke.

Options that depend on the dataset

Controls can derive their options from the dataset and the rest of the form through mapStateToProps. The canonical example is a select whose choices are the dataset’s columns filtered by type:

typescript
{
  name: 'geo_column',
  config: {
    type: 'SelectControl',
    label: t('Geometry column'),
    description: t('A column holding WKT or GeoJSON'),
    mapStateToProps: ({ datasource }) => ({
      choices: (datasource?.columns ?? [])
        .filter(col => /geom|geo|wkt|shape/i.test(col.column_name))
        .map(col => [col.column_name, col.verbose_name || col.column_name]),
    }),
    validators: [validateNonEmpty],
  },
}

mapStateToProps runs whenever the Explore state changes, so keep it cheap.

Validation

validators is an array of functions that take the value and return an error string or nothing. validateNonEmpty, validateInteger and validateNumber are provided; writing your own is one function:

typescript
const validateRange = (value: unknown) => {
  const n = Number(value);
  return n >= 0 && n <= 100 ? false : t('Must be between 0 and 100');
};

Explore shows the error under the control and disables the Update button until it clears. That is a better experience than letting a bad value through and handling it in transformProps.

Repeating groups

CollectionControl renders a list of sub-control groups the user can add to and remove from. It is how you get “add another threshold band” without writing a component. Each item is an object with the sub-control values, and it arrives in transformProps as an array.

If a requirement survives all of the above, you genuinely need a new component, and that means Part 3.

Building this control for more than one dashboard?

A one-off control panel is a weekend project; one that survives Superset upgrades and gets reused across dashboards is not. A Plugin Scoping Call scopes that difference before you commit engineering time.

Book a Plugin Scoping Call

Part 2: Making a chart act as a filter

A chart that filters the dashboard when the user clicks it is the most requested “custom filter” and needs no changes to Superset. It needs three things in the plugin.

First, declare the behaviour in index.ts:

typescript
behaviors: [Behavior.InteractiveChart],

Second, in transformProps, pass through the hooks Superset gives you:

typescript
const { hooks, filterState } = chartProps;
return {
  // ...other props
  setDataMask: hooks.setDataMask,
  selectedValues: filterState?.value ?? [],
  emitCrossFilters: formData.emitCrossFilters ?? false,
};

Third, in the component, call setDataMask when the user selects something, and clear it when they deselect:

typescript
function handleSelect(column: string, value: string | number, currentlySelected: boolean) {
  if (!emitCrossFilters) return;
  if (currentlySelected) {
    setDataMask({ extraFormData: { filters: [] }, filterState: { value: null } });
    return;
  }
  setDataMask({
    extraFormData: {
      filters: [{ col: column, op: 'IN', val: [value] }],
    },
    filterState: { value: [value], selectedValues: [value] },
  });
}

extraFormData.filters is what other charts receive as an additional where clause. filterState.value is what Superset uses to remember the selection, show it in the filter indicator, and hand it back to you as selectedValues so you can highlight the selected element. Set both, always; setting one leaves the dashboard confused.

The emitCrossFilters form-data flag is set by the dashboard’s cross-filter toggle, so respect it. Users expect to be able to turn cross-filtering off per dashboard.

Test this on a dashboard with at least two charts, not in Explore. Cross-filters do not exist outside a dashboard, and the failure modes (stale data in other charts, a selection that will not clear) only appear there.

Part 3: A genuinely new control type or native filter

Sometimes the requirement is a widget Superset does not have: a hierarchical tree picker for an org structure, a map-drawn bounding box, a date range with fiscal-quarter presets, a filter fed by an external service. Superset’s plugin API does not let a chart plugin register new control component types or new native filter types. Both are registries populated inside the Superset frontend, so both require a change to your Superset fork.

That is not a reason to avoid it. It is a reason to do it in a way that keeps the fork thin.

A new control component

  • Put the component in its own package, exactly as you would a chart plugin, so that it is versioned, tested and built independently. It receives value, onChange, and the config fields you declare, and it renders inside Explore’s control frame.
  • In the fork, register it in the control map. In 4.x that map lives in the Explore controls module (superset-frontend/src/explore/components/controls/index.js). One import and one entry:
javascript
import OrgTreeControl from 'superset-control-org-tree';
// ...
const controlMap = {
  // existing controls
  OrgTreeControl,
};
  • Reference it from any plugin by type: 'OrgTreeControl'.

Your fork diff is two lines. When Superset moves the control map, which it does occasionally, the rebase is a two-line conflict.

A new native filter type

Native filters are filter plugins in superset-frontend/src/filters, each with a control panel config, a buildQuery (for filters that need to fetch their own options) and a component that calls setDataMask just like a cross-filtering chart. Start by copying the select filter, which is the most complete example, and register the new plugin in the native filter preset alongside the built-ins.

The component contract is the same setDataMask call from Part 2. A native filter is, in effect, a chart that has no visualisation and lives in the filter bar. If you got a chart filtering the dashboard, you already know how to write one.

Keep the same discipline: the filter code in its own package, the registration in the fork.

When to say no

A new control or filter type is justified when the widget will be used across several charts or dashboards. For a one-off, restructure the requirement: a SelectControl with mapStateToProps and a CollectionControl cover more than people expect, and they upgrade for free.

Frequently Asked Questions

Can I add a custom control to a Superset chart plugin without forking Superset?

Yes, and that is the right default. A plugin’s control panel is built from the control types Superset already ships, and mapStateToProps gives them dataset-aware options, conditional visibility and validation. A separate plugin package survives upgrades; a fork turns every future Superset release into a merge.

How do I make one chart filter another dashboard?

Use cross-filters rather than building anything. A chart emits the clicked value as a filter that other charts on the dashboard respond to, which covers the majority of requests that arrive worded as “can this chart be a filter”. Reach for a custom native filter type only when the interaction genuinely cannot be expressed that way.

When is a genuinely new control type justified?

When the widget will be used across several charts or dashboards. For a one-off, restructure the requirement instead: SelectControl with mapStateToProps and CollectionControl cover more ground than people expect, and they inherit improvements from upstream for free. A bespoke control is a maintenance commitment at every upgrade.

Where does control state live?

In the form data that the control panel produces and buildQuery consumes, which is what makes a control’s value part of the saved chart and of the query it generates. Keeping state there, rather than in component-local state, is what lets a chart be saved, shared, embedded and rendered by the reports worker with the same result.

Build It With Andolasoft

Extending Superset beyond charts and filters follows the same rule of thin forks and separate packages. We wrote about that from a different angle in adding collaboration features to Apache Superset, and the mechanics of the plugin package itself are in the build guide. The wider service is on the Superset plugin development page.

If you are weighing a plugin-only approach against a fork for a specific requirement, that is exactly the question our Plugin Scoping Call answers: free, 45 minutes, and you leave with a written recommendation and estimate. If you would rather describe the requirement first, talk to us.

Superset Plugin Performance: Rendering 100k-Row Datasets Without Freezing the Dashboard

The complaint arrives as “the dashboard is slow”, and the first instinct is to look at the database. Check the query time in Superset’s chart data response and it is often under a second. The other nine seconds are in the browser: parsing the response, transforming it, and drawing a hundred thousand DOM nodes.Custom plugins are especially prone to this because they are written against a sample dataset and shipped before anyone tries them on the real one. (If you have not built one yet, start with how to build a custom chart plugin for Apache Superset; this post assumes that structure.) This post is about finding where the time goes and fixing it, in the order that gives the most improvement for the least rework.

Where the time goes

A chart render in Superset has five stages. Each can be the bottleneck, and the fix for each is different.

Stage Where it runs What makes it slow
Query Database Missing indexes, no aggregation, huge row limits
Transfer Network Large JSON payloads, no compression
Transform Browser, transformProps Per-row work, sorting, string building, repeated parsing
Render Browser, your component SVG node count, layout thrash, re-rendering on every prop
Interaction Browser Tooltips and hover on thousands of elements, cross-filter round trips

Measure before you change anything. Three tools give you the whole picture:

  • Superset’s own timing. Open the chart in Explore, use “View query” and “View as table” to see row counts, and check the network tab for the /api/v1/chart/data response time and size.
  • Chrome Performance panel. Record a dashboard load and look at the flame chart. Long yellow blocks under your plugin’s function names are transform time; long purple and green blocks are layout and paint.
  • React Profiler. Shows how many times your component rendered and why. A chart that renders four times per load is doing four times the work.

Write down the numbers. Ninety per cent of the time one stage dominates, and it is usually transform or render.

Fix 1: Do not fetch what you will not draw

A chart 900 pixels wide cannot display 100,000 distinct points. If the plugin fetches them anyway, every downstream stage pays for it. The right place to fix this is buildQuery, because it is the only stage where the database does the work.

Aggregate. If the chart shows a value per category, group by the category. If it shows a time series, apply a time grain. buildQueryContext does this from the standard controls; make sure the plugin’s controls expose them rather than pulling raw rows.

Limit series, not just rows. For multi-series charts, series_limit and series_limit_metric keep the top N series and drop the tail that nobody can read anyway.

Set a sensible `row_limit`. The default row limit is often far higher than any chart needs. A row limit control with a default matched to the chart’s capacity protects users from themselves.

Use server-side post-processing. Superset’s query object accepts a post_processing list of pandas operations that run on the backend after the query: pivot, aggregate, rolling, resample, cum, contribution, sort, rename, flatten. Rolling averages, pivots for a heatmap, and percentage-of-total calculations belong here, not in transformProps:

typescript
return buildQueryContext(formData, baseQueryObject => [
  {
    ...baseQueryObject,
    post_processing: [
      {
        operation: 'pivot',
        options: {
          index: ['__timestamp'],
          columns: formData.groupby,
          aggregates: { [metricLabel]: { operator: 'mean' } },
        },
      },
      {
        operation: 'rolling',
        options: { rolling_type: 'mean', window: 7, min_periods: 1, columns: { [metricLabel]: metricLabel } },
      },
    ],
  },
]);

The payload that reaches the browser is already in the shape the chart needs. This one change often removes the transform stage entirely.

Fix 2: Keep transformProps cheap and pure

transformProps runs on every render trigger, which means every time the user touches a cosmetic control. Anything expensive in it is paid repeatedly.

  • No per-row string formatting. Format values at draw time for the visible elements, or in the tooltip, not for every row up front.
  • No sorting of large arrays if the query can return them sorted (orderby in buildQuery).
  • No nested loops. Building a lookup map once and indexing into it is O(n); the nested find is O(n²) and 100,000 rows makes that ten billion operations.
  • Return plain data, not React elements. Keep transformProps framework-free so it is cheap to test and impossible to accidentally allocate components in.

A useful guardrail is a unit test that runs transformProps on a 100,000-row fixture and asserts it completes under a threshold. It fails the moment someone adds a quadratic step.

Fix 3: Choose the rendering technology for the data size

This is the change people reach for first, and it is the third most effective, because the first two usually make it unnecessary. When it is necessary, the guidance is simple.

SVG is fine up to a few thousand elements. Each element is a DOM node with layout, style and event handling. Past roughly 5,000 to 10,000 nodes the page becomes sluggish regardless of how well the code is written.

Canvas draws pixels, not nodes, and handles hundreds of thousands of points. ECharts, which Superset’s own charts use, renders to canvas by default. If your plugin wraps ECharts, three options matter for large data:

typescript
series: [{
  type: 'line',
  sampling: 'lttb',      // downsample to the visible pixel width while preserving shape
  large: true,           // enable large-data mode for bar and scatter
  largeThreshold: 2000,
  progressive: 4000,     // render in chunks so the UI stays responsive
  progressiveThreshold: 10000,
  data,
}]

sampling: 'lttb' (largest triangle three buckets) is the single most valuable one for time series: it reduces a 100,000-point line to the number of pixels available without losing peaks and troughs.

WebGL through deck.gl is for the cases canvas cannot handle: millions of points, geospatial layers, 3D. Superset ships deck.gl plugins, and the patrol analytics guide shows the pattern in a real deployment. The cost is complexity and a harder testing story, so reach for it only when the data justifies it.

Applied these and it’s still slow at your row counts?

Some performance ceilings are architectural, not fixable with these seven changes alone. A Plugin Scoping Call looks at your specific dataset shape and rendering approach.

Book a Plugin Scoping Call

Fix 4: Virtualise tables and lists

Table-style plugins are a special case. Users expect to see every row, so aggregation is not an option, but rendering 100,000 table rows as DOM nodes is exactly the SVG problem in a different costume.

The fix is virtualisation: render only the rows in the viewport plus a small buffer, and swap them as the user scrolls. react-window and @tanstack/react-virtual both do this well. Superset’s own table chart also supports server-side pagination, where each page is a separate query; that is the better choice when the dataset is large enough that even transferring it is slow.

Fix 5: Stop re-rendering

The React Profiler often shows a chart rendering several times per dashboard load: once with empty data, once with data, once when the dashboard finishes laying out, once when a filter indicator updates. Each render repeats any work not memoised.

  • Wrap expensive derived values in useMemo keyed on the data reference and the relevant props.
  • Memoise the component with React.memo if its props are stable.
  • For ECharts, call setOption with notMerge: false and update only the series that changed, rather than recreating the instance.
  • Make sure transformProps returns the same object references for unchanged data. A new array on every call defeats memoisation downstream.

Fix 6: Make interaction cheap

Tooltips that recompute on every mouse move, hover highlighting that touches every element, and cross-filter emissions that fire on drag are all ways to make a fast chart feel slow.

  • Throttle hover handlers to animation frames.
  • For canvas charts, use the library’s built-in hit testing rather than your own.
  • Emit cross-filters on click or on the end of a brush, not continuously.

Fix 7: Let long queries run asynchronously

When the database stage genuinely is slow, the dashboard should not freeze waiting for it. Superset’s global async queries feature runs chart queries through Celery workers and lets the browser poll or receive the result over a websocket. The dashboard renders immediately with loading states, and charts fill in as their data arrives. This is a deployment change (GLOBAL_ASYNC_QUERIES feature flag, Redis, Celery workers) rather than a plugin change, but plugin authors should test that their chart handles the loading and error states it produces.

Async queries do not make a slow query fast. They make a slow query stop blocking everything else. Pair them with caching and query tuning, which is a platform question covered in our production operations guide and in the Architecture Review.

A worked order of operations

Faced with a plugin that takes nine seconds on 100,000 rows:

  • Measure. Say the query is 0.8 s, transfer 0.6 s, transform 3.1 s, render 4.2 s.
  • Move aggregation and the rolling calculation into buildQuery with post-processing. Rows drop to 8,000. Transfer is now 0.1 s, transform 0.3 s, render 1.9 s.
  • Switch the SVG line to ECharts canvas with LTTB sampling. Render drops to 0.2 s.
  • Add useMemo around the option object. Renders per load drop from four to one.

Total: under two seconds, and most of it is the database. No WebGL, no virtualisation, no heroics. That is the usual shape of these fixes: the first two steps do most of the work.

Frequently Asked Questions

How many rows can a Superset chart plugin handle before it gets slow?

It depends on what you render with, not on Superset. SVG is comfortable up to a few thousand elements, because each one is a DOM node with layout, style and event handling, and becomes sluggish somewhere past five to ten thousand nodes however well the code is written. Canvas draws pixels rather than nodes and handles hundreds of thousands of points; WebGL through deck.gl goes further again, at the cost of complexity. Match the technology to the expected row count before writing the plugin.

Should aggregation happen in the browser or in the query?

In the query, nearly always. Aggregating by category, applying a time grain, setting a realistic row_limit, and limiting the number of series with series_limit all keep rows from being fetched at all. Rolling averages, pivots and percentage-of-total belong in the query object’s post_processing list, which runs on the backend, rather than in transformProps. The fastest transform is the one that has less data to transform.

Why does my chart render several times on every dashboard load?

Usually because a prop identity changes on each render, so memoisation never holds. The React Profiler shows the render count and the reason. A chart that renders four times per load does four times the transform and paint work, which is why re-render control often buys more than micro-optimising the transform itself.

What should transformProps avoid doing?

Per-row string formatting, sorting large arrays that the query could have returned sorted through orderby, nested loops, and allocating React elements. A nested find inside a loop is O(n squared), which at 100,000 rows is ten billion operations; building a lookup map once and indexing into it is O(n). Keep the function plain-data-in, plain-data-out and it stays cheap and testable.

Build It With Andolasoft

Performance is much cheaper to design in than to retrofit. When we scope a plugin, expected row counts and interaction patterns are part of the conversation, and they decide the rendering approach before any code is written. If you have a chart in mind, or one that has started to struggle, our Plugin Scoping Call is free and 45 minutes, and the written estimate you get afterwards will say which of the approaches above the design needs. Where the bottleneck turns out to be the platform rather than the plugin, that is the territory of a Superset Architecture Review.

If a dashboard in front of real users is freezing and you need to know whether the fix is the plugin, the query or the deployment, talk to us. We will profile a real load and tell you which of the three it is before anyone commits engineering time.

Superset Plugin Compatibility: What Breaks Between 3.x and 4.x and How to Migrate a Plugin

A custom chart plugin is compiled into the Superset frontend bundle. That is what makes it fast and first-class, and it is also why an upgrade can break it: the plugin is built against one version of @superset-ui/core and @superset-ui/chart-controls, and after the upgrade it runs against another. Minor releases rarely matter. Major ones, and the 3.x to 4.x jump in particular, changed enough that most plugins need at least a rebuild and some need real changes.This post is the checklist we work through when we carry a plugin across that line. It assumes you have a plugin structured the way the build guide describes: index.ts, buildQuery.ts, controlPanel.ts, transformProps.ts and a React component, registered in MainPreset.js.

One caveat up front. The exact list of breaking changes depends on the two versions you are moving between. The authoritative source is UPDATING.md in the Superset repository, read for every release between your current tag and your target. What follows is the set that has affected plugins we maintain.

Why plugins break at all

Three kinds of coupling exist between a plugin and the Superset it runs in:

  • Package coupling. The plugin imports from @superset-ui/core, @superset-ui/chart-controls and often @superset-ui/plugin-chart-echarts. These packages live inside the Superset monorepo and are versioned with it. A type that was exported in one version may be renamed or removed in the next.
  • Behavioural coupling. The plugin relies on how Superset builds queries, passes form data, applies filters and handles cross-filter events. Feature flags that flip their default between versions change this behaviour without changing any API.
  • Toolchain coupling. The plugin is compiled by Superset’s webpack, TypeScript and Babel configuration, on the Node version the Superset frontend requires. A stricter TypeScript setting or a newer Node can fail a build that has not changed.

Most upgrade pain comes from the second and third, because they do not show up as type errors.

What changed between 3.x and 4.x that plugin authors notice

Node and build tooling

The 4.x frontend requires Node 18. If your plugin’s CI still runs Node 16, the first symptom is a build failure with no obvious connection to your code. Align the Node version in your plugin repository, your Dockerfile and your CI with superset-frontend/package.json at the target tag.

TypeScript configuration also tightened across the 3.x line. Code that compiled with implicit any or loose null checks can start failing. Fix the types rather than loosening the config; the Superset build uses its own settings, not yours.

Peer dependency pins

Every plugin declares @superset-ui/core and @superset-ui/chart-controls as peer dependencies pinned to specific versions. Those versions moved through the 3.x and 4.x releases. A mismatch produces one of two failures:

  • The package manager resolves two copies of @superset-ui/core into the bundle. Registries and theme contexts are singletons, so the plugin’s copy cannot see the charts, colour schemes or theme registered by Superset’s copy. The symptom is an error along the lines of “theme is undefined” or a chart that never appears in the picker.
  • A type or helper the plugin imports no longer exists at the new version, which is a compile error and at least easy to find.

Update the pins to match the target Superset before doing anything else.

Time range and axis controls

The GENERIC_CHART_AXES feature flag, which lets any column be the x-axis and moves the time range into ad-hoc filters, became the default behaviour in the 3.x line and is the only behaviour in 4.x. Plugins built for 2.x or early 3.x often still use the legacy time section:

typescript
controlPanelSections: [
  sections.legacyRegularTime,
  // ...
]

This still compiles, and for regular (non-time-series) charts it still works. For time-series plugins it produces a control panel that does not match the rest of the product: users expect a time column and a time grain as controls, and the time range as a filter. Move to the generic axis pattern used by the ECharts time-series plugins: an x_axis control, a time_grain_sqla control, and no legacy time section. Your buildQuery then reads formData.x_axis and includes it in columns rather than assuming a __timestamp column.

Filter box removal

Superset 4.0 removed the legacy filter box chart. Plugins are rarely affected directly, but dashboards that used filter boxes to drive your chart now drive it through native filters, which arrive in extra_form_data on the query object. If your buildQuery manipulated extras or filters by hand, check that native filter values still reach the query. The buildQueryContext helper handles this correctly; hand-rolled query construction sometimes does not.

Cross-filter behaviours

Cross-filtering matured across 3.x and is on by default in 4.x. A plugin that declares Behavior.InteractiveChart is expected to emit and respond to cross-filters properly. Two things to check:

  • Emitting. The plugin should call setDataMask with both extraFormData.filters and filterState.value when the user selects an element, and clear both on deselect. Plugins that set only one of these leave the dashboard in an inconsistent state.
  • Receiving. When another chart filters this one, the filter arrives through the normal query path. Verify the chart re-queries rather than showing stale data.

If the plugin cannot participate in cross-filtering, remove Behavior.InteractiveChart from its metadata so the dashboard does not offer the option.

Theme tokens and styling

Superset’s theme object gained and reorganised tokens through 3.x. Plugins that reach into theme.colors.* or theme.gridUnit should be checked against the theme type exported by the target @superset-ui/core. Hard-coded colours keep working but look wrong in a customised deployment, which is the moment stakeholders notice.

Note for readers on the 5.x line: Superset 5 replaced the theme system with Ant Design 5 design tokens and a JSON theme format. That is a larger change than anything in 3.x to 4.x and deserves its own migration pass.

Content Security Policy

4.0 turned on Flask-Talisman and its Content Security Policy by default. Plugins that load scripts, fonts or images from external domains, for example a map tile server or a CDN-hosted library, are blocked unless TALISMAN_CONFIG allows those origins. This is a deployment change rather than a plugin change, but the plugin author is usually the one who has to explain the blank map.

Legacy visualisation migrations

4.0 completed the migration of several legacy charts to their ECharts replacements and removed the old implementations. This matters to plugin authors in one specific case: if your plugin extended or copied code from a legacy chart, the shared code it borrowed may be gone. Search your plugin for imports from legacy-plugin-chart-* packages.

Got more than one custom plugin to check?

Auditing every plugin against this list scales badly past two or three. A Plugin Scoping Call gets you a migration estimate across your whole plugin set, not just one at a time.

Book a Plugin Scoping Call

How to find out which of these affect you

Do this before touching any code.

  • Read `UPDATING.md` from your current tag to the target tag. Note every entry that mentions the frontend, feature flags, @superset-ui, or chart behaviour.
  • Diff the feature flag defaults between the two versions in superset/config.py. Any flag that flipped and touches charts or dashboards is a behavioural change your plugin will see.
  • Check the two package versions of @superset-ui/core and @superset-ui/chart-controls in superset-frontend/package.json at both tags, and read the changelogs for those packages.
  • Grep your plugin for the things listed above: legacyRegularTime, __timestamp, setDataMask, theme.colors, hard-coded colours, external URLs, and any import from a legacy- package.

You now have a list. Usually it is short.

Migration sequence

Work in this order; each step gives you a stable checkpoint.

  • Branch the plugin and name the branch for the target Superset version.
  • Bump the peer dependencies and dev dependencies to the target versions. Set Node to the target version.
  • Build the plugin alone (npm run build). Fix compile and type errors. This clears the package coupling.
  • Link it into a Superset checkout at the target tag and run the dev server. Open the chart in Explore. This surfaces registry and theme problems immediately.
  • Work through the behavioural list: time controls, filters, cross-filters, CSP. Test each in a dashboard, not only in Explore, because filters and cross-filters only exist on dashboards.
  • Restore or re-record thumbnails if the chart’s appearance changed.
  • Run the tests and update fixtures for any changed ChartProps shape.
  • Build the production image from the target tag with the plugin included, deploy to staging, and load every saved chart that uses the plugin. A saved chart carries the form data it was created with; charts saved under the old control panel are where migration bugs hide.

Budget half a day for a minor version and one to two days for a major one, assuming a single plugin of moderate complexity. Multiply for plugins that render maps or use WebGL.

Tests that make the next upgrade cheaper

  • Golden `ChartProps` fixtures. Save a real chartProps object from the running product for each chart configuration you support and assert transformProps output against it. When the shape changes, the diff tells you exactly what moved.
  • Saved-chart smoke test. A script that lists every saved chart with your viz_type and loads each one’s data endpoint. Run it against staging after every upgrade.
  • Control panel snapshot. A test that renders the control panel config and snapshots the control names. A rename in sharedControls shows up here instead of in production.

Keeping the fork thin

The lines you maintain inside the Superset repository should be two: the dependency in superset-frontend/package.json and the registration in MainPreset.js. Every upgrade is a rebase of those two lines onto the new tag. If your fork has grown beyond that, the upgrade cost is no longer about the plugin, and it is worth moving the extra code into the plugin package or a separate extension before the next major version.

Frequently Asked Questions

Will my Superset 3.x plugin work on 4.x without any changes?

Sometimes, but do not plan for it. A plugin that only uses stable @superset-ui/core exports and no time-range controls often survives untouched. Anything that uses the legacy time section, the filter box, hard-coded colours or the older cross-filter callbacks will need work. The only reliable answer comes from building the plugin against the target version and reading the errors.

What is the most common cause of a plugin breaking after a Superset upgrade?

Two copies of @superset-ui/core in the same bundle. Registries and theme contexts are singletons, so when the plugin’s copy differs from Superset’s copy, the plugin cannot see the charts, colour schemes or theme that Superset registered. It usually surfaces as “theme is undefined” or a chart that never appears in the picker. Align the peer dependency pins before investigating anything else.

The filter box was removed in 4.x. Do I have to rewrite my plugin?

Only if your plugin depended on it. The filter box was a chart type, not a plugin API, so most custom charts are unaffected. What does change is the surrounding dashboard: filters now come from native dashboard filters and cross-filters, so a plugin that read filter state the old way needs updating to the current hooks.

How long does a 3.x to 4.x plugin migration usually take?

For a single plugin with current dependency pins and no legacy time controls, a day is realistic, most of it spent on the build tooling and the test pass. Plugins that span more than one major version, borrow from legacy chart code, or have no tests take substantially longer, and the estimate is worth getting before the upgrade is scheduled rather than after.

Build It With Andolasoft

If the plugin was written by someone who has left, if it borrows from legacy chart code, or if the upgrade spans more than one major version, the cheapest first step is a scoping conversation rather than a spike. Our Plugin Scoping Call is free and 45 minutes; you leave with a written estimate of the migration effort. The wider service is described on the Superset plugin development page, and if the upgrade itself is the worry, the Architecture Review covers upgrade readiness for the whole deployment.

If a Superset upgrade is already on the calendar and the custom charts are the unknown, book a Plugin Scoping Call. We will review the plugins you have, tell you which of the changes above apply to each, and give you a written migration estimate before you commit to an upgrade date.

How to Build a Custom Chart Plugin for Apache Superset 4.x

Apache Superset ships with more than fifty chart types, and for most dashboards that is plenty. Then someone asks for a bullet chart against a target band, a Sankey with a custom node order, a map layer the built-in deck.gl charts do not offer, or a KPI tile that colours itself against three thresholds instead of one. At that point you have two options: bend an existing chart until it almost fits, or write a plugin.Plugins are how Superset itself is built. Every chart in the Explore view, from the humble table to the ECharts time series, is a plugin registered through the same ChartPlugin API you are about to use. Nothing about a custom plugin is second-class. It gets the same query layer, the same control panel framework, the same dashboard filters and cross-filters, and the same theming.

This guide builds a small but complete plugin for Superset 4.x. The chart is deliberately simple, a bar per category with a configurable highlight threshold, so that the plugin mechanics stay in focus. Swap the rendering for ECharts, D3, or deck.gl once the plumbing is in place.

What a plugin is made of

A Superset chart plugin is an npm package that exports a class extending ChartPlugin from @superset-ui/core. The class wires together five things:

Piece File Job
Metadata index.ts Name, description, thumbnail, category, tags, and behaviours shown in the chart picker
Query builder buildQuery.ts Turns the form data from the control panel into one or more query objects for Superset’s backend
Control panel controlPanel.ts Declares which controls appear in Explore and how they are grouped
Props transformer transformProps.ts Converts raw query results and form data into the props your component needs
Component HelloChart.tsx The React component that draws the chart

The flow at runtime is: the user changes a control, Superset runs buildQuery, sends the query to the backend, receives rows, calls transformProps with those rows plus the form data, and renders your component with the result. Controls flagged as renderTrigger skip the query and go straight to transformProps, which is how colour and label changes stay instant.

Prerequisites

  • Node.js at the version your Superset release pins. Read the engines field in superset-frontend/package.json at the tag you deploy rather than trusting a blog post: the 4.x line started on Node 18 and later releases accept Node 20 as well. Use nvm or fnm; a mismatched Node version is the most common cause of a build that fails for no obvious reason.
  • A checkout of the Superset repository at the exact tag you run in production, for example git clone --branch 4.1.1 https://github.com/apache/superset.git. You will run the frontend dev server from it and, later, build the production image from it.
  • A running Superset backend to develop against. The repo’s docker-compose setup works, or a local superset run -p 8088 --with-threads --reload --debugger.
  • Working knowledge of React and TypeScript. Plugins are TypeScript by default and there is no reason to fight that.

Step 1: Scaffold the plugin

Superset maintains a Yeoman generator that produces a working plugin skeleton. Install it and run it in a new directory next to, not inside, your Superset checkout:

bash
npm install -g yo @superset-ui/generator-superset

mkdir superset-plugin-chart-hello
cd superset-plugin-chart-hello
yo @superset-ui/superset

Answer the prompts: choose Chart plugin, accept the package name, add a one-line description, and pick Regular as the chart type (choose Time-series if your chart has a time axis; it changes the default controls). The generator writes a package.json, TypeScript config, Jest setup, a src/ folder with the five files above, and a src/images/thumbnail.png placeholder.

If the published generator lags behind the Superset version you are targeting, run it from your checkout instead:

bash
cd superset/superset-frontend/packages/generator-superset
npm install
npm link
cd ../../../../superset-plugin-chart-hello
yo @superset-ui/superset

Open package.json and check the peerDependencies. The generator pins @superset-ui/core and @superset-ui/chart-controls to the versions in the checkout you ran it from. These must match the Superset you deploy, or the plugin will compile against one API and run against another.

Step 2: The five files

The generated code works as-is. The point of walking through each file is to know what to change when your chart needs something different.

index.ts: metadata and wiring

typescript
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
import buildQuery from './buildQuery';
import controlPanel from './controlPanel';
import transformProps from './transformProps';
import thumbnail from './images/thumbnail.png';

export default class HelloChartPlugin extends ChartPlugin {
  constructor() {
    const metadata = new ChartMetadata({
      name: t('Hello Chart'),
      description: t('One bar per category, with bars above a threshold highlighted.'),
      thumbnail,
      category: t('Custom'),
      tags: [t('Comparison'), t('Custom')],
    });

    super({
      buildQuery,
      controlPanel,
      loadChart: () => import('./HelloChart'),
      metadata,
      transformProps,
    });
  }
}

loadChart is a dynamic import so the component is code-split and only downloaded when someone opens a dashboard that uses it. Wrap every user-visible string in t() so it goes through Superset’s translation layer.

Note what is not here: a behaviors array. Adding Behavior.InteractiveChart tells Superset the chart emits cross-filters, and it does put cross-filter controls in the UI, but the affordance does nothing until your component actually calls setDataMask, which Superset passes in through chartProps.hooks. Declaring the behaviour before you wire the hook produces a menu item that silently fails. Add the behaviour and the hook together, or neither. Dashboard native filters apply to your chart either way; they arrive as part of the query, not through this flag.

buildQuery.ts: from form data to a query

typescript
import { buildQueryContext, QueryFormData } from '@superset-ui/core';

export default function buildQuery(formData: QueryFormData) {
  const { cols: groupby } = formData;
  return buildQueryContext(formData, baseQueryObject => [
    {
      ...baseQueryObject,
      groupby,
    },
  ]);
}

buildQueryContext assembles a query object from the standard controls (metrics, filters, row limit, time range) and hands it to your callback. You return an array of query objects; one is normal, two or more is how charts like the big number with trendline fetch both a total and a series. The generator names the group-by control cols and maps it onto the query’s groupby. Recent Superset versions also accept columns; either works on 4.x.

controlPanel.ts: what the user can change

typescript
import { t, validateNonEmpty } from '@superset-ui/core';
import {
  ControlPanelConfig,
  sharedControls,
} from '@superset-ui/chart-controls';

const config: ControlPanelConfig = {
  controlPanelSections: [
    {
      label: t('Query'),
      expanded: true,
      controlSetRows: [
        [
          {
            name: 'cols',
            config: {
              ...sharedControls.groupby,
              label: t('Category column'),
              description: t('One bar per distinct value'),
            },
          },
        ],
        [
          {
            name: 'metrics',
            config: {
              ...sharedControls.metrics,
              validators: [validateNonEmpty],
            },
          },
        ],
        ['adhoc_filters'],
        ['row_limit'],
      ],
    },
    {
      label: t('Hello Chart options'),
      expanded: true,
      controlSetRows: [
        [
          {
            name: 'threshold',
            config: {
              type: 'TextControl',
              isInt: true,
              default: 0,
              renderTrigger: true,
              label: t('Highlight threshold'),
              description: t('Bars at or above this value are highlighted'),
            },
          },
        ],
        [
          {
            name: 'highlight_color',
            config: {
              type: 'ColorPickerControl',
              default: { r: 26, g: 169, b: 202, a: 1 },
              renderTrigger: true,
              label: t('Highlight colour'),
            },
          },
        ],
      ],
    },
  ],
};

export default config;

Two things to notice. First, sharedControls gives you the same metric, group-by and filter controls every built-in chart uses, so the Explore experience is consistent. Second, renderTrigger: true on the threshold and colour controls means changing them re-renders without re-querying the database. Anything that only affects drawing should be a render trigger; anything that changes what data comes back should not.

The generator’s template may also import a sections helper and open the panel with a time section such as sections.legacyRegularTime. Whether that export exists depends on the release you pin: the time controls were reworked across the 4.x line as the generic chart axes behaviour became the default. Check what your @superset-ui/chart-controls actually exports before importing it, because a missing export takes out the whole control panel rather than one control. The config above needs no time section at all; adhoc_filters covers time filtering.

Control names are snake_case here. They arrive in transformProps as camelCase (highlight_color becomes highlightColor), because ChartProps runs the form data through a camelCase conversion and keeps the original on rawFormData. This conversion is the single most common source of “my control value is undefined”.

transformProps.ts: shaping data for the component

typescript
import { ChartProps, DataRecord } from '@superset-ui/core';

export interface HelloChartProps {
  width: number;
  height: number;
  data: DataRecord[];
  categoryColumn: string;
  metricLabel: string;
  threshold: number;
  highlightColor: string;
}

export default function transformProps(chartProps: ChartProps): HelloChartProps {
  const { width, height, formData, queriesData } = chartProps;
  const { cols, metrics, threshold, highlightColor } = formData;

  const data = (queriesData[0]?.data ?? []) as DataRecord[];
  const metric = metrics?.[0];
  const metricLabel =
    typeof metric === 'string' ? metric : metric?.label ?? 'value';
  const rgba = highlightColor
    ? `rgba(${highlightColor.r}, ${highlightColor.g}, ${highlightColor.b}, ${highlightColor.a})`
    : '#1aa9ca';

  return {
    width,
    height,
    data,
    categoryColumn: Array.isArray(cols) ? cols[0] : cols,
    metricLabel,
    threshold: Number(threshold) || 0,
    highlightColor: rgba,
  };
}

queriesData is an array with one entry per query object you returned from buildQuery. Each entry has a data array of row objects keyed by column or metric label. Metrics can be plain strings (saved metrics) or ad-hoc metric objects with a label; handle both. Keep this function pure and cheap; it runs on every render trigger.

HelloChart.tsx: the component

tsx
import React from 'react';
import { styled } from '@superset-ui/core';
import { HelloChartProps } from './transformProps';

const Wrapper = styled.div<{ height: number; width: number }>`
  height: ${({ height }) => height}px;
  width: ${({ width }) => width}px;
  box-sizing: border-box;
  display: flex;
  align-items: stretch;
  gap: 6px;
  padding: 8px;
  font-family: ${({ theme }) => theme.typography.families.sansSerif};
`;

/* One column per row: bar area on top, label pinned underneath. */
const Column = styled.div`
  flex: 1 1 0;
  min-width: 0;
  display: flex;
  flex-direction: column;
`;

/* Gives the bar a definite height to take a percentage of. */
const BarArea = styled.div`
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  align-items: flex-end;
`;

const Bar = styled.div<{ pct: number; color: string }>`
  width: 100%;
  height: ${({ pct }) => pct}%;
  background-color: ${({ color }) => color};
  border-radius: 2px 2px 0 0;
`;

const Label = styled.div`
  flex: 0 0 auto;
  padding-top: 4px;
  font-size: 11px;
  text-align: center;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
`;

export default function HelloChart({
  width,
  height,
  data,
  categoryColumn,
  metricLabel,
  threshold,
  highlightColor,
}: HelloChartProps) {
  const max = Math.max(1, ...data.map(row => Number(row[metricLabel]) || 0));

  return (
    <Wrapper width={width} height={height}>
      {data.map(row => {
        const value = Number(row[metricLabel]) || 0;
        const pct = (value / max) * 100;
        const hot = value >= threshold;
        const category = String(row[categoryColumn]);
        return (
          <Column key={category} title={`${category}: ${value}`}>
            <BarArea>
              <Bar pct={pct} color={hot ? highlightColor : '#ccc'} />
            </BarArea>
            <Label>{category}</Label>
          </Column>
        );
      })}
    </Wrapper>
  );
}

The nesting is worth a moment, because it is the part people get wrong first. A percentage height only resolves against a parent with a definite height. Wrapper gets one from the pixel height Superset hands it, and BarArea gets one from being a flex child of Wrapper, so height: ${pct}% on the bar works. Put that percentage directly inside an auto-height div and every bar collapses to nothing.

Otherwise this is plain HTML and CSS on purpose. It has no chart library dependency, it is trivially testable, and it demonstrates the two things every component must do: fill the width and height Superset gives it, and read its theme from @superset-ui/core rather than hard-coding fonts. For anything more demanding, look at how @superset-ui/plugin-chart-echarts wraps ECharts, or how the deck.gl plugins handle WebGL. The pattern is the same; only the drawing changes.

This chart type more involved than a tutorial covers?

If your plugin needs custom interactions, non-standard data shapes, or has to survive the next major-version bump, a Superset Plugin Scoping Call gets you a build estimate before you sink a sprint into it.

Book a Plugin Scoping Call

Step 3: Run it inside Superset

The plugin has to be part of the Superset frontend bundle. Superset does carry an experimental DYNAMIC_PLUGINS feature flag that fetches a plugin from a URL at runtime, but it has stayed experimental for years and nothing in the dashboard experience is built around it, so treat compiling into the bundle as the supported path.

The package’s entry point is its build output, not its source, so build it before linking:

bash
cd superset-plugin-chart-hello
npm i --force
npm run build

The --force is there because the generator’s peer dependency ranges rarely resolve cleanly against a single Superset checkout. Then link the package into the Superset frontend:

bash
cd ../superset/superset-frontend
npm i -S ../../superset-plugin-chart-hello

Then open src/visualizations/presets/MainPreset.js and add the plugin alongside the built-ins:

javascript
import HelloChartPlugin from 'superset-plugin-chart-hello';

// ... inside the plugins array passed to super()
new HelloChartPlugin().configure({ key: 'hello_chart' }),

The key is the chart’s viz_type. It is stored on every saved chart that uses the plugin, so choose it once and never change it.

Now run three processes: the backend, the Superset dev server, and a watch build for the plugin.

bash
# terminal 1, from the repo root with your virtualenv active
superset run -p 8088 --with-threads --reload --debugger

# terminal 2
cd superset/superset-frontend
npm run dev-server

# terminal 3, in the plugin directory
npm run dev

That third terminal is not optional. Superset resolves the linked package through its main field, which points at compiled output, so editing src/HelloChart.tsx changes nothing until the plugin is rebuilt. npm run dev is the generator’s watch build; check the scripts block in your package.json if the name differs. Without it you will edit code for twenty minutes and conclude that hot reloading is broken.

Open http://localhost:9000, create a chart, and search for “Hello Chart” in the picker. Pick a dataset, a category column and a metric, and you should see bars. Change the threshold and watch the bars recolour without a spinner, which confirms the render trigger is wired correctly.

If the dev server does not pick up a rebuilt plugin, restart it; watching across a symlink is occasionally flaky on Windows and in Docker volumes.

Step 4: Getting it into production

Because the plugin is compiled into the bundle, production means building Superset’s frontend with your plugin included. The official apache/superset image does not contain the frontend source, so you build from the repository at the tag you deploy:

  • In your Superset checkout, add the plugin dependency to superset-frontend/package.json and the registration line in MainPreset.js. Use a git URL or a private registry entry, not the relative path you developed against: the plugin directory sits outside the Docker build context, so a relative dependency fails at npm ci rather than merely being untidy. Commit both changes on a branch named for the Superset version, for example 4.1.1-acme.
  • Build the image from the repo root: docker build -t registry.example.com/superset:4.1.1-acme .. The Dockerfile’s frontend build stage runs npm ci and the frontend build, which now includes your plugin.
  • Deploy that image in place of the official one. Nothing else changes: same configuration, same metadata database, same Helm chart or compose file.

When you upgrade Superset, rebase the branch onto the new tag, bump the plugin’s @superset-ui/* peer dependencies to match, rebuild, and run the plugin’s tests. Budget half a day for a minor version and more for a major one; the ChartPlugin API is stable, but control-panel helpers and theme tokens do move.

Step 5: Tests worth writing

The generator sets up Jest. Three tests pay for themselves immediately:

  • transformProps with a realistic ChartProps fixture: assert the metric label resolution for both string and ad-hoc metrics, the camelCase control names, and the empty queriesData case.
  • buildQuery with sample form data: assert the query object contains the expected groupby and metrics, so a control rename does not silently produce an empty query.
  • The component with React Testing Library: render with three rows, assert three bars, and assert the highlighted count for a given threshold.

Superset’s frontend also has a Storybook (superset-frontend/storybook). Adding a story for your plugin gives designers and stakeholders a place to look at it without a running backend.

Pitfalls that cost time

  • Peer dependency drift. @superset-ui/core in your plugin must be the version the Superset frontend uses. Two copies of the library in one bundle produce baffling errors about themes or registries being undefined.
  • Forgetting the plugin’s watch build. The linked package serves compiled output, so source edits are invisible until it rebuilds.
  • camelCase vs snake_case. Controls are defined in snake_case and read in camelCase. Log formData once in transformProps if a value is missing.
  • Forgetting renderTrigger. Every cosmetic control without it forces a database round trip.
  • Declaring behaviours you have not implemented. Behavior.InteractiveChart without a setDataMask call gives users a cross-filter option that does nothing.
  • Percentage sizing inside auto-height containers. Give the parent a definite height or the chart renders empty at full data.
  • Hard-coded colours and fonts. Read them from the theme so the chart looks right in a themed or white-labelled Superset.
  • Changing the viz_type key. Saved charts reference it. Renaming it orphans every chart built on the plugin.
  • Ignoring width and height. Superset tells your component its size. A chart that sizes itself overflows dashboard grid cells.
  • Forking Superset core. Put chart logic in the plugin package, not in the Superset repo. The only lines you should be maintaining in the fork are the dependency and the registration.

Going further

Once the skeleton renders, the next problems tend to arrive in this order:

Frequently Asked Questions

Do I have to fork Superset to add a custom chart plugin?

Not for the chart itself. The plugin lives in its own npm package, and that is where all of its logic belongs. It does have to be compiled into the Superset frontend bundle, because the DYNAMIC_PLUGINS flag is still experimental, so you keep a branch of Superset at your production tag with two small changes: the plugin dependency in superset-frontend/package.json and its registration in MainPreset.js.

What files make up a Superset chart plugin?

Five: index.ts for metadata and wiring, buildQuery.ts to turn form data into a query, controlPanel.ts to declare the Explore controls, transformProps.ts to shape query results into props, and the React component that draws the chart.

Why does changing a control re-run the database query?

Because the control is not marked renderTrigger: true. Controls without it go back through buildQuery and the backend; controls with it skip the query and go straight to transformProps. Anything that only affects drawing, such as colours, labels or thresholds, should be a render trigger.

What happens to my plugin when I upgrade Superset?

Rebase your Superset branch onto the new tag, bump the plugin's @superset-ui/* peer dependencies to match, rebuild, and run the plugin's tests. The ChartPlugin API is stable, but control-panel helpers and theme tokens do move, so budget half a day for a minor version and more for a major one.

Build It With Andolasoft

The mechanics above are the same ones behind the plugins we have written up on this blog: a dual-axis line chart for Superset 4 that plots two measures on independent axes and takes part in cross-filtering, and a multi-threshold heatmap for Superset 4.1 that colours cells against several hard limits instead of one gradient. Both started as exactly the skeleton in this post.

If you have a chart in mind and want to know what it would take, our Plugin Scoping Call is a free 45-minute session with a plugin engineer. You describe the visualisation, we walk through the data shape and interactions, and within 48 hours you get a written scope, effort estimate, and timeline you can act on with or without us. The wider service is described on our Superset plugin development page.

To talk through a chart you need built, or a plugin that has stopped working after an upgrade, contact us.