Axis range cannot be changed in bokeh dashboard if data updated

In a bokeh dashboard, I am trying to programmatically change the x-axis range while changing the data source.

See the following example, in which I want two datasets (data1_0 and data1_1) to be displayed with one x-range (x_range1), two other datasets (data2_0 and data2_1) with another range (x_range2).

import bokeh.io
from bokeh.io import curdoc
import bokeh.layouts
from bokeh.plotting import figure, show
from bokeh.models import Range1d, DataRange1d, Select

# Define data:
data1_0 = {'x-values': [1, 2, 3, 4, 5],
         'y-values': [1, 2, 3, 4, 5]}

data1_1 = {'x-values': [2, 3, 4],
         'y-values': [2, 3, 4]}


data2_0 = {'x-values': [4, 5, 6, 7, 8],
         'y-values': [1, 2, 3, 4, 5]}

data2_1 = {'x-values': [6, 7],
         'y-values': [3, 4]}

# Define x ranges:
x_range1 = [0.9, 5.1]
x_range2 = [3.9, 8.1]

# Also tested with Range1d and DataRange1d:
# x_range1 = DataRange1d(start=0.9, end=5.1)
# x_range2 = DataRange1d(start=3.9, end=8.1)

# x_range1 = Range1d(start=0.9, end=5.1)
# x_range2 = Range1d(start=3.9, end=8.1)

# Set up figure:
p = figure(y_range=(0.9, 5.1))
line = p.line(source=data1_0, x='x-values', y='y-values')
print('Initial setting of x_range1...')
# p.x_range = x_range1
p.x_range.start = x_range1[0]
p.x_range.end = x_range1[1]
print('x_range1 has been set.')

# Introduce an "on_change" callback to see if a change of the x axis range takes place:
def x_range_callback(attr, old, new):
    print('x_range_callback called:', p.x_range.start, ' ', p.x_range.end)

p.x_range.on_change('end', x_range_callback)

# Build a selector:
selector = Select(title='Data source', value="data1_0", options=["data1_0", "data1_1", "data2_0", "data2_1"])

# Set up the selector callback:
def select_callback(attr, old, new):
    print('New data source selected, setting', new, 'as new data source.')
    line.data_source.data = eval(new) # <-- Without this line, scaling works well!
    if new=='data1_0' or new=='data1_1':
        print('Setting range1:')
        p.x_range.start = x_range1[0]
        p.x_range.end = x_range1[1]
        # p.x_range = x_range1
        print('Finished setting range1.')
    else:
        print('Setting range2:')
        p.x_range.start = x_range2[0]
        p.x_range.end = x_range2[1]
        # p.x_range = x_range2
        print('Finished setting range2.')
    print('select_callback() ended.')

selector.on_change('value', select_callback)

# Set up layout:
my_layout = bokeh.layouts.row(children = [p, bokeh.layouts.Spacer(width=20), selector])

curdoc().add_root(my_layout)

The initial setting of the x_range works well, but any attempt to change it to other values afterwards will be directly overrode by my initial values.

Here is the log of the print() commands I introduced to see what happens:

Initial setting of x_range1...
x_range1 has been set.
New data source selected, setting data2_0 as new data source.
Setting range2:
x_range_callback called: 3.9   8.1  <---- This is what should be set.
Finished setting range2.
select_callback() ended.
x_range_callback called: 0.9   5.1  <---- This comes automatically and overrides the previous setting.
New data source selected, setting data2_1 as new data source.
Setting range2:
x_range_callback called: 3.9   8.1  <---- This is what should be set.
Finished setting range2.
select_callback() ended.
x_range_callback called: 0.9   5.1  <---- This comes automatically and overrides the previous setting.

A important observation is that when I comment out the line:
line.data_source.data = eval(new)
then rescaling works as expected.

Additional observations:
If I comment out the initial setting of x_range, the later settings have no effect, automatic scaling is always applied.
If after setting x_range1 I directly set x_range2 before displaying the plot, then x_range2 always comes back.

So it seems that the range applied when first displaying the plot is “burned” into the plot and cannot be changed afterwards.

Am I missing something?
What can I do to have the expected behavior?
Also, is there a possibility to programmatically set back the default automatic scaling of the x-axis if the fixing is not needed anymore?

If this is a bug, where exactly should I report it?

This topic seems to be related to following topics:

and others…

Here are the used versions:
python version: 3.11.0
bokeh version: 3.1.0

Behaves the same with:
Python 3.9.12
bokeh 2.4.2

Keywords: x_range, y_range, dashboard, scaling, scale, rescale

Hi @Icoti I think I understand what you want, but I don’t really follow your code. What I think you want is to be able to switch between glyphs using a drop-down, and to have the ranges update automatically as a result. If that’s the case, then I’d say your code is over-complicated. On that assumption, let me just present how I would approach this task, and you can comment how or if it’s not what you are looking for. (Note that my version is not a Bokeh server app, just run it like normal Python script.)

from bokeh.layouts import row, Spacer
from bokeh.models import CustomJS, Select
from bokeh.plotting import figure, show

data1_0 = {"x": [1, 2, 3, 4, 5], "y": [1, 3, 3, 4, 5]}
data1_1 = {"x": [2, 3, 4], "y": [2, 5, 1]}
data2_0 = {"x": [4, 5, 6, 7, 8], "y": [5, 2, 3, 4, 5]}
data2_1 = {"x": [6, 7], "y": [3, 4]}

p = figure()

# only consider the visible glyphs when auto-ranging
p.x_range.only_visible = p.y_range.only_visible = True

# add all the lines up front, but leave only one visible
line1_0 = p.line("x", "y", source=data1_0)
line1_1 = p.line("x", "y", source=data1_1, visible=False)
line2_0 = p.line("x", "y", source=data2_0, visible=False)
line2_1 = p.line("x", "y", source=data2_1, visible=False)

options=["data1_0", "data1_1", "data2_0", "data2_1"]
select = Select(title="Data source", value="data1_0", options=options)

lines = dict(zip(options, [line1_0, line1_1, line2_0, line2_1]))

select.js_on_change("value", CustomJS(args=dict(select=select, lines=lines),  code="""
    // make all the lines invisble
    Object.values(lines).map(value => value.visible = false)

    // except the one corresponding to the user selection
    lines[select.value].visible = true
"""))

show(row(p, Spacer(width=20), select))

I’ve taken the liberty of tweaking the fake data to make it a little more obvious when the plot changes.

In a nutshell: by far the best approach to this kind of thing in Bokeh is to add all the glyphs up front, and then use a callback so simply toggle their visibilities appropriately.

Hi Bryan,
thanks for the reply.
Actually, my original code is even more complicated (and most probably not optimal, I am not a professional programmer).
On my original code, I have a dashboard with graphs on one tab.
On another tab, the user can select a file with a FileInput widget.
Then, the new data from the new file is displayed.
Afterwards, some filters may be applied, the filtered data has to be displayed with the same x_range as before applying filters.
In a nutshell, I need to be able to flexible set x_range and change it even after the data source for the glyph has been changed.
At that point I am stuck, because changing the data source seems to make any later change of the x_range impossible.
I could post my whole original code but it only works on a specific data layout so it would be difficult to run it without this data. Additionally, I would have to clean it before because it contains company intern information.
Though if you mind it would really help, I could do it.
But I think the code posted in my previous post should help you understand what I want if you assume that data2_0 is data coming from the FileInput widget and data2_1 is the filtered data from data2_0.
Of course, the user should be able afterwards to choose other files and display them so adding all the glyphs up front is not an option.
If my description is still not clear feel free to ask for more details.

@Icoti Based on that description, here is how I would handle that (simulated). This version is a Bokeh app. It updates the data based on the select, and adjusts the x_range at that time to match the data at that time. There is a separate button for “filtering” which updates the data, but does not update the range (so it does not change from when the current Select data was originally set). Based on your description above, that sounds closer to your actual UX, and is simpler to code too.

from bokeh.io import curdoc
from bokeh.layouts import column, row, Spacer
from bokeh.models import Button, ColumnDataSource, Select
from bokeh.plotting import figure

data1 = dict(x=[1, 2, 3, 4, 5], y=[1, 3, 3, 4, 5])
data2 = dict(x=[4, 5, 6, 7, 8], y=[5, 2, 3, 4, 5])

source = ColumnDataSource()

# we will update x_range based on data later
p = figure(x_range=(0, 1), y_range=(0.9, 5.1))
p.line("x", "y", source=source)

def update_data():
    source.data = data1 if select.value == "data1" else data2
    p.x_range.start = min(source.data["x"])
    p.x_range.end = max(source.data["x"])

select = Select(title="Data source", value="data1", options=["data1", "data2"])
select.on_change("value", lambda attr, old, new: update_data())

def filter_data():
    if len(source.data["x"]) == 5:
        new_data = dict(x=source.data["x"][1:-1], y=source.data["y"][1:-1])

        # important: update source.data "atomically"
        source.data = new_data

button = Button(name="Filter")
button.on_click(filter_data)

# initialize once to match starting Select
update_data()

curdoc().add_root(row(p, Spacer(width=20), column(select, button)))

@Bryan Thanks for this additional code which is indeed much closer to my UX, and solves my problem too!
Actually, one small detail (in my eyes) makes the difference, namely the setting of a dummy x_range when creating the plot in the line:

p = figure(x_range=(0, 1), y_range=(0.9, 5.1))

If I add x_range=(0, 1), at the corresponding position in my initial code, everything works as expected.
Conversely, if I remove it from the code in your second post, then the subsequent settings of x_range don’t work properly.
It seems that this initialization in the figure() function while creating the plot is necessary to be able to correctly change the axis range afterwards.
First setting the axis range later, for instance with p.x_range =, has not the same effect and leads to problems.

Well, it will depend on details. But by default, figure creates auto-ranging DataRange1d objects for the ranges, which bring in a lot of complexity[1], and thus more possibilities for weird or bad interactions. By passing explicit x_range=(0, 1) (or whatever) that causes a “dumb” Range1d to be used instead, which is much much less complex, and better suited for your use-case, anyway, since you want to manage the range extents yourself.

First setting the axis range later, for instance with p.x_range = , has not the same effect and leads to problems.

Changing out entire range objects after the first render / initialization on the browser side is, for all practical purposes, not actually supported. There’s just too many events set up to trigger off ranges to make that really do-able. Possibly we should find some way in the API to enforce or loudly warn about this.

Otherwise it’s good to keep in mind the general best practice for Bokeh: always try to make the smallest change possible, on existing objects, i.e. set start and end on the existing range, don’t try to replace the range.


  1. Off the top of my head, auto-ranges have to coordinate, all at the same time: querying and coalescing bounding regions for all glyphs, or possibly only some glyphs depending on visibility or configuration, range “following” mode for streaming data, applying absolute or relative range padding, enforcing min-interval and max-interval configurations and hard-bounds, if they are given, respecting user-supplied overrides for start/end, also for all that also handle linear or log scale differences, also all that possibly in a linked range scenario across multiple plots, and emitting range update and LOD events when expected… without any exaggeration, auto-ranges are one of the single most complicated things in all of Bokeh, by far. ↩︎