Repository: holoviz/holoviews
Pull request #6476 (2024-12-13 16:20:31)

refactor: Remove direct pandas import
This PR introduces the utilities `_lazy_module` and `gen_types`. Both are meant to depend on 3rd party libraries without using direct imports. 

These have been used throughout the code, so `pandas` is no longer imported directly; this has significantly improved import time and, at the same time, set up the codebase to handle a world where `pandas` is not the only DataFrame library. Some functionality can likely be rewritten to not depend on `pandas`, but I will do that in future PRs.  

The `_lazy_module` has also "sneaked" into the Ibis data interface, which, in my opinion, makes it cleaner by not having all methods start with `import ibis`. 

Lastly, I have also removed the use of `lazy_isinstance` and deprecated it as the functionality can be done with `_lazy_module` and a bool check. This is done in `holoviews/operation/datashader.py`. 

## Some statics:

### Hyperfine

Main (0ba7398ae61f15fb27e5066514d2bb04badcb0df)

``` bash
❯ hyperfine "python -c ''" "python -c 'import holoviews'" --warmup 5
Benchmark 1: python -c ''
  Time (mean ± σ):      16.0 ms ±   0.6 ms    [User: 12.0 ms, System: 3.9 ms]
  Range (min … max):    14.9 ms …  17.6 ms    171 runs

Benchmark 2: python -c 'import holoviews'
  Time (mean ± σ):     367.3 ms ±  11.7 ms    [User: 1938.8 ms, System: 57.8 ms]
  Range (min … max):   356.4 ms … 391.3 ms    10 runs

Summary
  python -c '' ran
   22.90 ± 1.10 times faster than python -c 'import holoviews'
```

This PR (https://github.com/holoviz/holoviews/pull/6476/commits/18f4db439ede2490b27f4eff3bbb602a0a79dcf8)
``` bash
❯ hyperfine "python -c ''" "python -c 'import holoviews'" --warmup 5
Benchmark 1: python -c ''
  Time (mean ± σ):      16.2 ms ±   0.6 ms    [User: 12.0 ms, System: 4.0 ms]
  Range (min … max):    14.9 ms …  19.1 ms    179 runs

Benchmark 2: python -c 'import holoviews'
  Time (mean ± σ):     189.7 ms ±   9.2 ms    [User: 1781.3 ms, System: 29.2 ms]
  Range (min … max):   182.5 ms … 216.8 ms    15 runs

Summary
  python -c '' ran
   11.74 ± 0.71 times faster than python -c 'import holoviews'
```

### Tuna

Running `python -X importtime -c 'import holoviews' 2> tuna.log && tuna tuna.log`

Main (0ba7398ae61f15fb27e5066514d2bb04badcb0df)

![image](https://github.com/user-attachments/assets/b9b44df8-83b5-41d2-aafe-2b3d179f6324)


This PR (https://github.com/holoviz/holoviews/pull/6476/commits/18f4db439ede2490b27f4eff3bbb602a0a79dcf8)
![image](https://github.com/user-attachments/assets/569a1d38-de1e-4f11-bc03-085d763e3cb5)




<details><summary>Output of <code>hv.show_versions</code></summary>

``` yaml
Python              :  3.13.2 | packaged by conda-forge | (main, Feb 17 2025, 14:10:22) [GCC 13.3.0]
Operating system    :  Linux-6.13.5-arch1-1-x86_64-with-glibc2.41
Panel comms         :  default

holoviews           :  1.20.1.post1.dev7+g95d3af8b2

bokeh               :  3.6.3
colorcet            :  3.1.0
cudf                :  -
cupy                :  13.4.0
dask                :  2025.2.0
dask-expr           :  -
datashader          :  0.17.0.post1.dev5+gc3e4b58
geoviews            :  1.14.0.post1.dev2+gd89e2f7
hvplot              :  0.11.3.dev9+g9700bec.d20250301
ibis-framework      :  10.1.0
IPython             :  8.32.0
ipywidgets-bokeh    :  1.6.0
jupyter-bokeh       :  4.0.5
jupyterlab          :  4.3.5
matplotlib          :  3.10.1
networkx            :  3.4.2
notebook            :  -
numba               :  0.61.0
numpy               :  2.1.3
pandas              :  2.2.3
panel               :  1.6.1.post1.dev4+gf5170c481.d20250301
param               :  2.2.0.post1.dev22+gee19961
pillow              :  11.1.0
plotly              :  6.0.0
pyarrow             :  19.0.1
pyviz-comms         :  3.0.4
scikit-image        :  0.25.2
scipy               :  1.15.2
spatialpandas       :  0.5.0
streamz             :  -
tsdownsample        :  0.1.4.1
xarray              :  2025.1.2


```


</details> 


Patch:
diff --git a/examples/user_guide/Interactive_Hover_for_Big_Data.ipynb b/examples/user_guide/Interactive_Hover_for_Big_Data.ipynb
index 0c0d0acc4..6336d8447 100644
--- a/examples/user_guide/Interactive_Hover_for_Big_Data.ipynb
+++ b/examples/user_guide/Interactive_Hover_for_Big_Data.ipynb
@@ -171,7 +171,7 @@
     "\n",
     "This design avoids sending all the raw data to the client and only transmits the necessary information on-demand. You can enable this by adding a `selector` to a `rasterize` or `datashade` operation. \n",
     "\n",
-    "By adding an `selector` we now get the information of all the dimensions in the DataFrame along with the original Hover Information, for this example the new information is `s`, `val`, and `cat`. "
+    "By adding an `selector` we now get the information of all the dimensions in the DataFrame along with the original Hover Information, for this example the new information is `s`, `val`, and `cat`. The new information is split with a horizontal rule."
    ]
   },
   {
@@ -190,7 +190,7 @@
    "id": "90f093c4-3a3a-4c2f-9d5b-bbb6a1963d43",
    "metadata": {},
    "source": [
-    "You can specify which columns to show in the HoverTool with `hover_tooltips`. "
+    "You can specify which columns to show in the HoverTool with `hover_tooltips`. You can also rename the label by passing in a tuple with `(label, column)`. The information about the selector itself can be disabled by setting `selector_in_hovertool=False`."
    ]
   },
   {
@@ -200,7 +200,18 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "rasterized_with_selector.opts(hover_tooltips=[\"x\", \"y\", \"s\", \"cat\"])"
+    "hover_tooltips=[\"x\", \"y\", (\"s [mean(s)]\", \"x_y s\"), (\"s [min(s)]\", \"s\"), (\"cat [min(s)]\", \"cat\")]\n",
+    "rasterized_with_selector.opts(hover_tooltips=hover_tooltips, selector_in_hovertool=False)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "214f31a0-16fe-4fc7-acc1-34e0bbe4f5ef",
+   "metadata": {},
+   "source": [
+    ":::{note}\n",
+    "When `selector` is enabled, hover tooltips use a predefined grid layout with fixed styling, limiting the customization options that would otherwise be available through `hover_tooltips` in standard plots that don't utilize `selector`.\n",
+    ":::"
    ]
   },
   {
diff --git a/holoviews/core/__init__.py b/holoviews/core/__init__.py
index f24658a02..aa5d7827d 100644
--- a/holoviews/core/__init__.py
+++ b/holoviews/core/__init__.py
@@ -1,7 +1,5 @@
 from datetime import date, datetime
 
-import pandas as pd
-
 from .boundingregion import *
 from .data import *
 from .dimension import *
@@ -31,7 +29,7 @@ Dimension.type_formatters[np.float64] = "%.5g"
 Dimension.type_formatters[np.datetime64] = '%Y-%m-%d %H:%M:%S'
 Dimension.type_formatters[datetime] = '%Y-%m-%d %H:%M:%S'
 Dimension.type_formatters[date] = '%Y-%m-%d'
-Dimension.type_formatters[pd.Timestamp] = "%Y-%m-%d %H:%M:%S"
+Dimension.type_formatters['pandas._libs.tslibs.timestamps.Timestamp'] = "%Y-%m-%d %H:%M:%S"
 
 
 def public(obj):
diff --git a/holoviews/core/data/cudf.py b/holoviews/core/data/cudf.py
index 78a795f9d..0a28311f9 100644
--- a/holoviews/core/data/cudf.py
+++ b/holoviews/core/data/cudf.py
@@ -3,8 +3,6 @@ import warnings
 from itertools import product
 
 import numpy as np
-import pandas as pd
-from pandas.api.types import is_numeric_dtype
 
 from .. import util
 from ..dimension import dimension_name
@@ -49,6 +47,7 @@ class cuDFInterface(PandasInterface):
     @classmethod
     def init(cls, eltype, data, kdims, vdims):
         import cudf
+        import pandas as pd
 
         element_params = eltype.param.objects()
         kdim_param = element_params['kdims']
@@ -276,6 +275,9 @@ class cuDFInterface(PandasInterface):
 
     @classmethod
     def aggregate(cls, dataset, dimensions, function, **kwargs):
+        import pandas as pd
+        from pandas.api.types import is_numeric_dtype
+
         data = dataset.data
         cols = [d.name for d in dataset.kdims if d in dimensions]
         vdims = dataset.dimensions('value', label='name')
diff --git a/holoviews/core/data/dask.py b/holoviews/core/data/dask.py
index 4df091af8..a6161154b 100644
--- a/holoviews/core/data/dask.py
+++ b/holoviews/core/data/dask.py
@@ -1,7 +1,6 @@
 import sys
 
 import numpy as np
-import pandas as pd
 
 from .. import util
 from ..dimension import Dimension
@@ -252,6 +251,7 @@ class DaskInterface(PandasInterface):
                 agg = getattr(reindexed, inbuilts[function.__name__])()
             else:
                 raise NotImplementedError
+            import pandas as pd
             df = pd.DataFrame(agg.compute()).T
 
         dropped = []
diff --git a/holoviews/core/data/ibis.py b/holoviews/core/data/ibis.py
index c99fa2bc5..cfcfd3b78 100644
--- a/holoviews/core/data/ibis.py
+++ b/holoviews/core/data/ibis.py
@@ -1,15 +1,21 @@
-import sys
 from collections.abc import Iterable
+from typing import TYPE_CHECKING
 
 import numpy as np
 
 from .. import util
 from ..element import Element
 from ..ndmapping import NdMapping, item_check, sorted_context
+from ..util.dependencies import _LazyModule, _no_import_version
 from .interface import DataError, Interface
 from .util import cached
 
-IBIS_VERSION = util._no_import_version("ibis-framework")
+if TYPE_CHECKING:
+    import ibis
+else:
+    ibis = _LazyModule("ibis", "ibis-framework", bool_use_sys_modules=True)
+
+IBIS_VERSION = _no_import_version("ibis-framework")
 IBIS_GE_4_0_0 = IBIS_VERSION >= (4, 0, 0)
 IBIS_GE_5_0_0 = IBIS_VERSION >= (5, 0, 0)
 IBIS_GE_9_5_0 = IBIS_VERSION >= (9, 5, 0)
@@ -30,7 +36,6 @@ class IbisInterface(Interface):
     # the rowid is needed until ibis updates versions
     @classmethod
     def has_rowid(cls):
-        import ibis.expr.operations
         return hasattr(ibis.expr.operations, "RowID")
 
     @classmethod
@@ -44,7 +49,7 @@ class IbisInterface(Interface):
 
     @classmethod
     def loaded(cls):
-        return "ibis" in sys.modules
+        return bool(ibis)
 
     @classmethod
     def applies(cls, obj):
@@ -141,8 +146,6 @@ class IbisInterface(Interface):
         compute=True,
         keep_index=False,
     ):
-        import ibis
-
         dimension = dataset.get_dimension(dimension, strict=True)
         data = dataset.data[dimension.name]
         if (
@@ -196,7 +199,6 @@ class IbisInterface(Interface):
         if by is None:
             by = []
         if IBIS_VERSION >= (6, 0, 0):
-            import ibis
             order = ibis.desc if reverse else ibis.asc
             return dataset.data.order_by([order(dataset.get_dimension(x).name) for x in by])
         elif IBIS_GE_4_0_0:
@@ -219,7 +221,6 @@ class IbisInterface(Interface):
 
     @classmethod
     def _index_ibis_table(cls, data):
-        import ibis
         if not cls.has_rowid():
             raise ValueError(
                 f"iloc expressions are not supported for ibis version {ibis.__version__}."
@@ -340,7 +341,6 @@ class IbisInterface(Interface):
 
     @classmethod
     def add_dimension(cls, dataset, dimension, dim_pos, values, vdim):
-        import ibis
         data = dataset.data
         if dimension.name not in data.columns:
             if not isinstance(values, ibis.Expr) and not np.isscalar(values):
@@ -390,7 +390,6 @@ class IbisInterface(Interface):
 
     @classmethod
     def select_mask(cls, dataset, selection):
-        import ibis
         predicates = []
         for dim, object in selection.items():
             if isinstance(object, tuple):
@@ -437,7 +436,6 @@ class IbisInterface(Interface):
 
     @classmethod
     def sample(cls, dataset, samples=None):
-        import ibis
         if samples is None:
             samples = []
         dims = dataset.dimensions()
@@ -468,7 +466,6 @@ class IbisInterface(Interface):
 
     @classmethod
     def aggregate(cls, dataset, dimensions, function, **kwargs):
-        import ibis.expr.operations
         data = dataset.data
         columns = [d.name for d in dataset.kdims if d in dimensions]
         values = dataset.dimensions("value", label="name")
diff --git a/holoviews/core/data/pandas.py b/holoviews/core/data/pandas.py
index c63417899..fdea717ba 100644
--- a/holoviews/core/data/pandas.py
+++ b/holoviews/core/data/pandas.py
@@ -1,15 +1,21 @@
+from typing import TYPE_CHECKING
+
 import numpy as np
-import pandas as pd
-from pandas.api.types import is_numeric_dtype
 
 from .. import util
 from ..dimension import Dimension, dimension_name
 from ..element import Element
 from ..ndmapping import NdMapping, item_check, sorted_context
-from ..util import PANDAS_GE_2_1_0
+from ..util.dependencies import PANDAS_GE_2_1_0, _LazyModule
 from .interface import DataError, Interface
 from .util import finite_range
 
+if TYPE_CHECKING:
+    import pandas as pd
+else:
+    pd = _LazyModule("pandas")
+
+
 
 class PandasAPI:
     """This class is used to describe the interface as having a pandas-like API.
@@ -26,10 +32,20 @@ class PandasAPI:
 
 class PandasInterface(Interface, PandasAPI):
 
-    types = (pd.DataFrame,)
-
     datatype = 'dataframe'
 
+    @classmethod
+    def loaded(cls):
+        # 2025-02: As long as it is a required dependency and to not break
+        # existing behavior we will for now always return True
+        return bool(pd)
+
+    @classmethod
+    def applies(cls, obj):
+        if not cls.loaded():
+            return False
+        return isinstance(obj, (pd.DataFrame, pd.Series))
+
     @classmethod
     def dimension_type(cls, dataset, dim):
         return cls.dtype(dataset, dim).type
@@ -289,6 +305,7 @@ class PandasInterface(Interface, PandasAPI):
                     c for c in reindexed.columns if c not in cols
                 ]
             else:
+                from pandas.api.types import is_numeric_dtype
                 numeric_cols = [
                     c for c, d in zip(reindexed.columns, reindexed.dtypes, strict=None)
                     if is_numeric_dtype(d) and c not in cols
diff --git a/holoviews/core/data/spatialpandas.py b/holoviews/core/data/spatialpandas.py
index 45becfd8c..eadbbbef6 100644
--- a/holoviews/core/data/spatialpandas.py
+++ b/holoviews/core/data/spatialpandas.py
@@ -2,7 +2,6 @@ import sys
 from collections import defaultdict
 
 import numpy as np
-import pandas as pd
 
 from ..dimension import dimension_name
 from ..util import isscalar, unique_array, unique_iterator
@@ -98,6 +97,8 @@ class SpatialPandasInterface(MultiInterface):
         elif 'geometry' not in data:
             cls.geo_column(data)
 
+        import pandas as pd
+
         index_names = data.index.names if isinstance(data, pd.DataFrame) else [data.index.name]
         if index_names == [None]:
             index_names = ['index']
@@ -906,6 +907,7 @@ def from_multi(eltype, data, kdims, vdims):
     -------
     A GeoDataFrame containing in the list based format.
     """
+    import pandas as pd
     from spatialpandas import GeoDataFrame
 
     xname, yname = (kd.name for kd in kdims[:2])
diff --git a/holoviews/core/data/xarray.py b/holoviews/core/data/xarray.py
index f47c46bcc..48b56cdf7 100644
--- a/holoviews/core/data/xarray.py
+++ b/holoviews/core/data/xarray.py
@@ -2,7 +2,6 @@ import sys
 import types
 
 import numpy as np
-import pandas as pd
 
 from .. import util
 from ..dimension import Dimension, asdim, dimension_name
@@ -653,6 +652,8 @@ class XArrayInterface(GridInterface):
 
     @classmethod
     def sample(cls, dataset, samples=None):
+        import pandas as pd
+
         if samples is None:
             samples = []
         names = [kd.name for kd in dataset.kdims]
diff --git a/holoviews/core/dimension.py b/holoviews/core/dimension.py
index a41798159..0fbfbafab 100644
--- a/holoviews/core/dimension.py
+++ b/holoviews/core/dimension.py
@@ -405,6 +405,19 @@ class Dimension(param.Parameterized):
         kws = ", ".join(f'{k}={changed[k]!r}' for k in ordering if k != 'name')
         return f'Dimension({self.name!r}, {kws})'
 
+    def _get_type_formatters(self, own_type):
+        """_get_type_formatters returns the formatter for the type of the value.
+
+        It first checks if the type itself is in self.type_formatters and if
+        not, it checks if the qualified name of the type type is in
+        self.type_formatters.
+        """
+        if own_type in self.type_formatters:
+            return self.type_formatters[own_type]
+
+        own_type_str = f"{own_type.__module__}.{own_type.__qualname__}"
+        if own_type_str in self.type_formatters:
+            return self.type_formatters[own_type_str]
 
     def pprint_value(self, value, print_unit=False):
         """Applies the applicable formatter to the value.
@@ -420,7 +433,7 @@ class Dimension(param.Parameterized):
         """
         own_type = type(value) if self.type is None else self.type
         formatter = (self.value_format if self.value_format
-                     else self.type_formatters.get(own_type))
+                     else self._get_type_formatters(own_type))
         if formatter:
             if callable(formatter):
                 formatted_value = formatter(value)
diff --git a/holoviews/core/element.py b/holoviews/core/element.py
index c8a9e2520..d86d70dab 100644
--- a/holoviews/core/element.py
+++ b/holoviews/core/element.py
@@ -1,7 +1,6 @@
 from itertools import groupby
 
 import numpy as np
-import pandas as pd
 import param
 
 from .dimension import Dimensioned, ViewableElement, asdim
@@ -243,6 +242,8 @@ class Element(ViewableElement, Composable, Overlayable):
         -------
         DataFrame of columns corresponding to each dimension
         """
+        import pandas as pd
+
         if dimensions is None:
             dimensions = [d.name for d in self.dimensions()]
         else:
diff --git a/holoviews/core/ndmapping.py b/holoviews/core/ndmapping.py
index 9026de5e0..1e20d9ad4 100644
--- a/holoviews/core/ndmapping.py
+++ b/holoviews/core/ndmapping.py
@@ -8,7 +8,6 @@ from itertools import cycle
 from operator import itemgetter
 
 import numpy as np
-import pandas as pd
 import param
 
 from . import util
@@ -963,6 +962,8 @@ class UniformNdMapping(NdMapping):
         -------
         DataFrame of columns corresponding to each dimension
         """
+        import pandas as pd
+
         if dimensions is None:
             outer_dimensions = self.kdims
             inner_dimensions = None
diff --git a/holoviews/core/util.py b/holoviews/core/util/__init__.py
similarity index 97%
rename from holoviews/core/util.py
rename to holoviews/core/util/__init__.py
index 83ddb7f31..db5bcf6b0 100644
--- a/holoviews/core/util.py
+++ b/holoviews/core/util/__init__.py
@@ -12,23 +12,36 @@ import pickle
 import string
 import sys
 import time
-import types
 import unicodedata
 import warnings
 from collections import defaultdict, namedtuple
 from contextlib import contextmanager
 from functools import partial
-from importlib.metadata import PackageNotFoundError, version
 from threading import Event, Thread
-from types import FunctionType
+from types import FunctionType, GeneratorType
 
 import numpy as np
-import pandas as pd
 import param
-from packaging.version import Version
-from pandas.core.arrays.masked import BaseMaskedArray
-from pandas.core.dtypes.dtypes import DatetimeTZDtype
-from pandas.core.dtypes.generic import ABCExtensionArray, ABCIndex, ABCSeries
+
+from .dependencies import (  # noqa: F401
+    NUMPY_GE_2_0_0,
+    NUMPY_VERSION,
+    PANDAS_GE_2_1_0,
+    PANDAS_GE_2_2_0,
+    PANDAS_VERSION,
+    PARAM_VERSION,
+    VersionError,
+)
+from .types import (
+    arraylike_types,
+    cftime_types,
+    datetime_types,
+    generator_types,  # noqa: F401
+    masked_types,
+    pandas_datetime_types,
+    pandas_timedelta_types,
+    timedelta_types,
+)
 
 # Python 2 builtins
 basestring = str
@@ -38,41 +51,16 @@ cmp = lambda a, b: (a>b)-(a<b)
 
 get_keywords = operator.attrgetter('varkw')
 
-# Versions
-NUMPY_VERSION = Version(np.__version__).release
-PARAM_VERSION = Version(param.__version__).release
-PANDAS_VERSION = Version(pd.__version__).release
-
-NUMPY_GE_2_0_0 = NUMPY_VERSION >= (2, 0, 0)
-PANDAS_GE_2_1_0 = PANDAS_VERSION >= (2, 1, 0)
-PANDAS_GE_2_2_0 = PANDAS_VERSION >= (2, 2, 0)
-
-# Types
-generator_types = (zip, range, types.GeneratorType)
-pandas_datetime_types = (pd.Timestamp, DatetimeTZDtype, pd.Period)
-pandas_timedelta_types = (pd.Timedelta,)
-datetime_types = (np.datetime64, dt.datetime, dt.date, dt.time, *pandas_datetime_types)
-timedelta_types = (np.timedelta64, dt.timedelta, *pandas_timedelta_types)
-arraylike_types = (np.ndarray, ABCSeries, ABCIndex, ABCExtensionArray)
-masked_types = (BaseMaskedArray,)
-
-try:
-    import cftime
-    cftime_types = (cftime.datetime,)
-    datetime_types += cftime_types
-except ImportError:
-    cftime_types = ()
-_STANDARD_CALENDARS = {'standard', 'gregorian', 'proleptic_gregorian'}
-
 anonymous_dimension_label = '_'
 
 # Argspec was removed in Python 3.11
 ArgSpec = namedtuple('ArgSpec', 'args varargs keywords defaults')
 
-_NP_SIZE_LARGE = 1_000_000
-_NP_SAMPLE_SIZE = 1_000_000
-_PANDAS_ROWS_LARGE = 1_000_000
-_PANDAS_SAMPLE_SIZE = 1_000_000
+_STANDARD_CALENDARS = {'standard', 'gregorian', 'proleptic_gregorian'}
+_ARRAY_SIZE_LARGE = 1_000_000
+_ARRAY_SAMPLE_SIZE = 1_000_000
+_DATAFRAME_ROWS_LARGE = 1_000_000
+_DATAFRAME_SAMPLE_SIZE = 1_000_000
 
 # To avoid pandas warning about using DataFrameGroupBy.function
 # introduced in Pandas 2.1.
@@ -107,28 +95,6 @@ _PANDAS_FUNC_LOOKUP = {
     np.nancumsum: "cumsum",
 }
 
-
-class VersionError(Exception):
-    """Raised when there is a library version mismatch.
-
-    """
-
-    def __init__(self, msg, version=None, min_version=None, **kwargs):
-        self.version = version
-        self.min_version = min_version
-        super().__init__(msg, **kwargs)
-
-
-def _no_import_version(name) -> tuple[int, int, int]:
-    """Get version number without importing the library
-
-    """
-    try:
-        return Version(version(name)).release
-    except PackageNotFoundError:
-        return (0, 0, 0)
-
-
 class Config(param.ParameterizedFunction):
     """Set of boolean configuration values to change HoloViews' global
     behavior. Typically used to control warnings relating to
@@ -213,14 +179,15 @@ class HashableJSON(json.JSONEncoder):
             h = hashlib.new("md5")
             for s in obj.shape:
                 h.update(_int_to_bytes(s))
-            if obj.size >= _NP_SIZE_LARGE:
+            if obj.size >= _ARRAY_SIZE_LARGE:
                 state = np.random.RandomState(0)
-                obj = state.choice(obj.flat, size=_NP_SAMPLE_SIZE)
+                obj = state.choice(obj.flat, size=_ARRAY_SAMPLE_SIZE)
             h.update(obj.tobytes())
             return h.hexdigest()
+        import pandas as pd
         if isinstance(obj, (pd.Series, pd.DataFrame)):
-            if len(obj) > _PANDAS_ROWS_LARGE:
-                obj = obj.sample(n=_PANDAS_SAMPLE_SIZE, random_state=0)
+            if len(obj) > _DATAFRAME_ROWS_LARGE:
+                obj = obj.sample(n=_DATAFRAME_SAMPLE_SIZE, random_state=0)
             try:
                 pd_values = list(pd.util.hash_pandas_object(obj, index=True).values)
             except TypeError:
@@ -298,7 +265,7 @@ def deprecated_opts_signature(args, kwargs):
     corresponding options.
 
     """
-    from .options import Options
+    from ..options import Options
     groups = set(Options._option_groups)
     opts = {kw for kw in kwargs if kw != 'clone'}
     apply_groups = False
@@ -539,7 +506,7 @@ def callable_name(callable_obj):
             return callable_obj.__name__
         elif inspect.ismethod(callable_obj):    # instance and class methods
             return callable_obj.__func__.__qualname__.replace('.__call__', '')
-        elif isinstance(callable_obj, types.GeneratorType):
+        elif isinstance(callable_obj, GeneratorType):
             return callable_obj.__name__
         else:
             return type(callable_obj).__name__
@@ -888,12 +855,13 @@ def isnat(val):
     """Checks if the value is a NaT. Should only be called on datetimelike objects.
 
     """
+    import pandas as pd
     if (isinstance(val, (np.datetime64, np.timedelta64)) or
         (isinstance(val, np.ndarray) and val.dtype.kind == 'M')):
         return np.isnat(val)
     elif val is pd.NaT:
         return True
-    elif isinstance(val, pandas_datetime_types+pandas_timedelta_types):
+    elif isinstance(val, (pandas_datetime_types, pandas_timedelta_types)):
         return pd.isna(val)
     else:
         return False
@@ -904,6 +872,7 @@ def isfinite(val):
     np.isfinite with support for None, string, datetime types.
 
     """
+    import pandas as pd
     is_dask = is_dask_array(val)
     if not np.isscalar(val) and not is_dask:
         if isinstance(val, np.ma.core.MaskedArray):
@@ -927,7 +896,7 @@ def isfinite(val):
         finite = np.isfinite(val)
         finite &= ~pd.isna(val)
         return finite
-    elif isinstance(val, datetime_types+timedelta_types):
+    elif isinstance(val, (datetime_types, timedelta_types)):
         return not isnat(val)
     elif isinstance(val, (str, bytes)):
         return True
@@ -1005,6 +974,7 @@ def max_range(ranges, combined=True):
     -------
     The maximum range as a single tuple
     """
+    import pandas as pd
     try:
         with warnings.catch_warnings():
             warnings.filterwarnings('ignore', r'All-NaN (slice|axis) encountered')
@@ -1212,6 +1182,7 @@ def unique_array(arr):
     if not len(arr):
         return np.asarray(arr)
 
+    import pandas as pd
     if isinstance(arr, np.ndarray) and arr.dtype.kind not in 'MO':
         # Avoid expensive unpacking if not potentially datetime
         return pd.unique(arr)
@@ -1535,6 +1506,7 @@ def is_dataframe(data):
     """Checks whether the supplied data is of DataFrame type.
 
     """
+    import pandas as pd
     dd = None
     if 'dask.dataframe' in sys.modules and 'pandas' in sys.modules:
         import dask.dataframe as dd
@@ -1546,6 +1518,7 @@ def is_series(data):
     """Checks whether the supplied data is of Series type.
 
     """
+    import pandas as pd
     dd = None
     if 'dask.dataframe' in sys.modules:
         import dask.dataframe as dd
@@ -1736,7 +1709,7 @@ def stream_name_mapping(stream, exclude_params=None, reverse=False):
     """
     if exclude_params is None:
         exclude_params = ['name']
-    from ..streams import Params
+    from ...streams import Params
     if isinstance(stream, Params):
         mapping = {}
         for p in stream.parameters:
@@ -1782,7 +1755,7 @@ def stream_parameters(streams, no_duplicates=True, exclude=None):
     """
     if exclude is None:
         exclude = ['name', '_memoize_key']
-    from ..streams import Params
+    from ...streams import Params
     param_groups = {}
     for s in streams:
         if not s.contents and isinstance(s.hashkey, dict):
@@ -1945,6 +1918,7 @@ class ndmapping_groupby(param.ParameterizedFunction):
     @param.parameterized.bothmethod
     def groupby_pandas(self_or_cls, ndmapping, dimensions, container_type,
                        group_type, sort=False, **kwargs):
+        import pandas as pd
         if 'kdims' in kwargs:
             idims = [ndmapping.get_dimension(d) for d in kwargs['kdims']]
         else:
@@ -2079,6 +2053,7 @@ def is_nan(x):
     try:
         # Using pd.isna instead of np.isnan as np.isnan(pd.NA) returns pd.NA!
         # Call bool() to raise an error if x is pd.NA, an array, etc.
+        import pandas as pd
         return bool(pd.isna(x))
     except Exception:
         return False
@@ -2145,6 +2120,7 @@ def date_range(start, end, length, time_unit='us'):
     of samples.
 
     """
+    import pandas as pd
     step = (1./compute_density(start, end, length, time_unit))
     if isinstance(start, pd.Timestamp):
         start = start.to_datetime64()
@@ -2156,6 +2132,7 @@ def parse_datetime(date):
     """Parses dates specified as string or integer or pandas Timestamp
 
     """
+    import pandas as pd
     return pd.to_datetime(date).to_datetime64()
 
 
@@ -2179,6 +2156,7 @@ def dt_to_int(value, time_unit='us'):
     """Converts a datetime type to an integer with the supplied time unit.
 
     """
+    import pandas as pd
     if isinstance(value, pd.Period):
         value = value.to_timestamp()
     if isinstance(value, pd.Timestamp):
@@ -2392,6 +2370,10 @@ def lazy_isinstance(obj, class_or_tuple):
         3) Do an isinstance check for dask.dataframe.DataFrame
 
     """
+    from ...util.warnings import deprecated
+
+    deprecated("1.23.0", "lazy_isinstance") # Not used in HoloViews anymore
+
     if isinstance(class_or_tuple, str):
         class_or_tuple = (class_or_tuple,)
 
diff --git a/holoviews/core/util/dependencies.py b/holoviews/core/util/dependencies.py
new file mode 100644
index 000000000..29350d7de
--- /dev/null
+++ b/holoviews/core/util/dependencies.py
@@ -0,0 +1,114 @@
+import re
+import sys
+from functools import lru_cache
+from importlib import import_module
+from importlib.metadata import PackageNotFoundError, version
+from importlib.util import find_spec
+
+_re_no = re.compile(r"\d+")
+
+class VersionError(Exception):
+    """Raised when there is a library version mismatch."""
+
+    def __init__(self, msg, version=None, min_version=None, **kwargs):
+        self.version = version
+        self.min_version = min_version
+        super().__init__(msg, **kwargs)
+
+
+@lru_cache
+def _is_installed(module_name):
+    return find_spec(module_name) is not None
+
+
+@lru_cache
+def _get_version(package_name):
+    try:
+        return version(package_name)
+    except PackageNotFoundError:
+        return "0.0.0"
+
+
+def _no_import_version(package_name) -> tuple[int, int, int]:
+    """Get version number without importing the library"""
+    version_str = _get_version(package_name)
+    return tuple(map(int, _re_no.findall(version_str)[:3]))
+
+
+_MIN_SUPPORTED_VERSION = {
+    "pandas": (1, 3, 0),
+}
+
+
+class _LazyModule:
+    def __init__(self, module_name, package_name=None, *, bool_use_sys_modules=False):
+        """
+        Lazy import module
+
+        This will wait and import the module when an attribute is accessed.
+
+        Parameters
+        ----------
+        module_name: str
+            The import name of the module, e.g. `import PIL`
+        package_name: str, optional
+            Name of the package, this is the named used for installing the package, e.g. `pip install pillow`.
+            Used for the __version__ if the module is not imported.
+            If not set uses the module_name.
+        bool_use_sys_modules: bool, optional, default False
+            Also check `sys.modules` for module in __bool__ check if True.
+            This means that bool can only be True if the module is already imported.
+        """
+        self.__module = None
+        self.__module_name = module_name
+        self.__package_name = package_name or module_name
+        self.__bool_use_sys_modules = bool_use_sys_modules
+
+    @property
+    def _module(self):
+        if self.__module is None:
+            self.__module = import_module(self.__module_name)
+            if self.__package_name in _MIN_SUPPORTED_VERSION:
+                min_version = _MIN_SUPPORTED_VERSION[self.__package_name]
+                mod_version = _no_import_version(self.__package_name)
+                if mod_version < min_version:
+                    min_version_str = ".".join(map(str, min_version))
+                    mod_version_str = ".".join(map(str, mod_version))
+                    msg = f"{self.__package_name} requires {min_version_str} or higher (found {mod_version_str})"
+                    raise VersionError(msg, mod_version_str, min_version_str)
+
+        return self.__module
+
+    def __getattr__(self, attr):
+        return getattr(self._module, attr)
+
+    def __dir__(self):
+        return dir(self._module)
+
+    def __bool__(self):
+        if self.__bool_use_sys_modules:
+            return bool(self.__module or (_is_installed(self.__module_name) and self.__module_name in sys.modules))
+        else:
+            return bool(self.__module or _is_installed(self.__module_name))
+
+    def __repr__(self):
+        if self.__module:
+            return repr(self.__module).replace("<module", "<lazy-module")
+        else:
+            return f"<lazy-module {self.__module_name!r}>"
+
+    @property
+    def __version__(self):
+        return self.__module and self.__module.__version__ or _get_version(self.__package_name)
+
+
+# Versions
+NUMPY_VERSION = _no_import_version("numpy")
+PARAM_VERSION = _no_import_version("param")
+PANDAS_VERSION = _no_import_version("pandas")
+
+NUMPY_GE_2_0_0 = NUMPY_VERSION >= (2, 0, 0)
+PANDAS_GE_2_1_0 = PANDAS_VERSION >= (2, 1, 0)
+PANDAS_GE_2_2_0 = PANDAS_VERSION >= (2, 2, 0)
+
+__all__ = ["NUMPY_GE_2_0_0", "NUMPY_VERSION", "PANDAS_GE_2_1_0", "PANDAS_GE_2_2_0", "PANDAS_VERSION", "PARAM_VERSION", "_LazyModule"]
diff --git a/holoviews/core/util/types.py b/holoviews/core/util/types.py
new file mode 100644
index 000000000..2d39e3f72
--- /dev/null
+++ b/holoviews/core/util/types.py
@@ -0,0 +1,116 @@
+import datetime as dt
+import inspect
+from types import GeneratorType
+from typing import TYPE_CHECKING
+
+from .dependencies import _LazyModule
+
+if TYPE_CHECKING:
+    import cftime
+    import numpy as np
+    import pandas as pd
+else:
+    cftime = _LazyModule("cftime", bool_use_sys_modules=True)
+    np = _LazyModule("numpy", bool_use_sys_modules=True)
+    pd = _LazyModule("pandas", bool_use_sys_modules=True)
+
+
+# gen_types is copied from param, can be removed when
+# we support 2.2 or greater
+class _GeneratorIsMeta(type):
+    def __instancecheck__(cls, inst):
+        return isinstance(inst, tuple(cls.types()))
+
+    def __subclasscheck__(cls, sub):
+        return issubclass(sub, tuple(cls.types()))
+
+    def __iter__(cls):
+        yield from cls.types()
+
+
+class _GeneratorIs(metaclass=_GeneratorIsMeta):
+    @classmethod
+    def __iter__(cls):
+        yield from cls.types()
+
+
+def gen_types(gen_func):
+    """
+    Decorator which takes a generator function which yields difference types
+    make it so it can be called with isinstance and issubclass.
+    """
+    if not inspect.isgeneratorfunction(gen_func):
+        msg = "gen_types decorator can only be applied to generator"
+        raise TypeError(msg)
+    return type(gen_func.__name__, (_GeneratorIs,), {"types": staticmethod(gen_func)})
+
+
+# Types
+generator_types = (zip, range, GeneratorType)
+
+
+@gen_types
+def pandas_datetime_types():
+    if pd:
+        from pandas.core.dtypes.dtypes import DatetimeTZDtype
+
+        yield from (pd.Timestamp, pd.Period, DatetimeTZDtype)
+
+
+@gen_types
+def pandas_timedelta_types():
+    if pd:
+        yield pd.Timedelta
+
+
+@gen_types
+def cftime_types():
+    if cftime:
+        yield cftime.datetime
+
+
+@gen_types
+def datetime_types():
+    yield from (dt.datetime, dt.date, dt.time)
+    if np:
+        yield np.datetime64
+    yield from pandas_datetime_types()
+    yield from cftime_types()
+
+
+@gen_types
+def timedelta_types():
+    yield dt.timedelta
+    if np:
+        yield np.timedelta64
+    yield from pandas_timedelta_types()
+
+
+@gen_types
+def arraylike_types():
+    if np:
+        yield np.ndarray
+    if pd:
+        from pandas.core.dtypes.generic import ABCExtensionArray, ABCIndex, ABCSeries
+
+        yield from (ABCIndex, ABCSeries, ABCExtensionArray)
+
+
+@gen_types
+def masked_types():
+    if pd:
+        from pandas.core.arrays.masked import BaseMaskedArray
+
+        yield BaseMaskedArray
+
+
+__all__ = [
+    "arraylike_types",
+    "cftime_types",
+    "datetime_types",
+    "generator_types",
+    "masked_types",
+    "pandas_datetime_types",
+    "pandas_timedelta_types",
+    "timedelta_types",
+]
diff --git a/holoviews/element/comparison.py b/holoviews/element/comparison.py
index 733706f39..02fc044d6 100644
--- a/holoviews/element/comparison.py
+++ b/holoviews/element/comparison.py
@@ -23,7 +23,6 @@ from unittest import TestCase
 from unittest.util import safe_repr
 
 import numpy as np
-import pandas as pd
 from numpy.testing import assert_array_almost_equal, assert_array_equal
 
 from ..core import (
@@ -43,6 +42,7 @@ from ..core import (
 )
 from ..core.options import Cycle, Options
 from ..core.util import cast_array_to_int64, datetime_types, dt_to_int, is_float
+from ..core.util.dependencies import _is_installed
 from . import *  # noqa (All Elements need to support comparison)
 
 
@@ -131,7 +131,9 @@ class Comparison(ComparisonInterface):
         cls.equality_type_funcs[np.ma.masked_array]  = cls.compare_arrays
 
         # Pandas dataframe comparison
-        cls.equality_type_funcs[pd.DataFrame] = cls.compare_dataframe
+        if _is_installed("pandas"):
+            import pandas as pd
+            cls.equality_type_funcs[pd.DataFrame] = cls.compare_dataframe
 
         # Dimension objects
         cls.equality_type_funcs[Dimension] =    cls.compare_dimensions
diff --git a/holoviews/element/graphs.py b/holoviews/element/graphs.py
index 3716d872b..fd1757523 100644
--- a/holoviews/element/graphs.py
+++ b/holoviews/element/graphs.py
@@ -2,7 +2,6 @@ from collections import defaultdict
 from types import FunctionType
 
 import numpy as np
-import pandas as pd
 import param
 
 from ..core import Dataset, Dimension, Element2D
@@ -69,6 +68,7 @@ class layout_nodes(Operation):
             target = element.dimension_values(1, expanded=False)
             nodes = np.unique(np.concatenate([source, target]))
             if self.p.layout:
+                import pandas as pd
                 df = pd.DataFrame({'index': nodes})
                 nodes = self.p.layout(df, element.dframe(), **self.p.kwargs)
                 nodes = nodes[['x', 'y', 'index']]
@@ -165,6 +165,8 @@ class Graph(Dataset, Element2D):
         return RedimGraph(self, mode='dataset')
 
     def _add_node_info(self, node_info):
+        import pandas as pd
+
         nodes = self.nodes.clone(datatype=['pandas', 'dictionary'])
         if isinstance(node_info, self.node_type):
             nodes = nodes.redim(**dict(zip(nodes.dimensions('key', label=True),
diff --git a/holoviews/element/selection.py b/holoviews/element/selection.py
index fec4a01b5..f8f92b0d8 100644
--- a/holoviews/element/selection.py
+++ b/holoviews/element/selection.py
@@ -7,7 +7,6 @@ import sys
 from importlib.util import find_spec
 
 import numpy as np
-import pandas as pd
 
 from ..core import Dataset, NdOverlay, util
 from ..streams import Lasso, Selection1D, SelectionXY
@@ -115,6 +114,7 @@ def _cuspatial_new(xvals, yvals, geometry):
 
 
 def spatial_select_columnar(xvals, yvals, geometry, geom_method=None):
+    import pandas as pd
     if 'cudf' in sys.modules:
         import cudf
         import cupy as cp
diff --git a/holoviews/element/util.py b/holoviews/element/util.py
index 12ffe791c..c9c49b1b8 100644
--- a/holoviews/element/util.py
+++ b/holoviews/element/util.py
@@ -4,7 +4,6 @@ import itertools
 from typing import TYPE_CHECKING
 
 import numpy as np
-import pandas as pd
 import param
 
 from ..core import Dataset
@@ -25,6 +24,8 @@ from ..core.util import (
 if TYPE_CHECKING:
     from typing import TypeVar
 
+    import pandas as pd
+
     Array = TypeVar("Array", np.ndarray, pd.api.extensions.ExtensionArray)
 
 
@@ -103,6 +104,7 @@ def reduce_fn(x):
     """Aggregation function to get the first non-zero value.
 
     """
+    import pandas as pd
     values = x.values if isinstance(x, pd.Series) else x
     for v in values:
         if not is_nan(v):
@@ -208,6 +210,7 @@ class categorical_aggregate2d(Operation):
                          datatype=self.p.datatype)
 
     def _aggregate_dataset_pandas(self, obj):
+        import pandas as pd
         index_cols = [d.name for d in obj.kdims]
         groupby_kwargs = {"sort": False}
         if PANDAS_GE_2_1_0:
@@ -281,6 +284,7 @@ def connect_edges_pd(graph):
     NumPy equivalent.
 
     """
+    import pandas as pd
     edges = graph.dframe()
     edges.index.name = 'graph_edge_index'
     edges = edges.reset_index()
@@ -307,6 +311,7 @@ def connect_tri_edges_pd(trimesh):
     NumPy equivalent.
 
     """
+    import pandas as pd
     edges = trimesh.dframe().copy()
     edges.index.name = 'trimesh_edge_index'
     edges = edges.drop("color", errors="ignore", axis=1).reset_index()
@@ -346,6 +351,8 @@ def connect_edges(graph):
 
 
 def sort_arr(arr: Array) -> Array:
+    import pandas as pd
+
     if isinstance(arr, pd.api.extensions.ExtensionArray):
         return arr[arr.argsort()]
     return np.sort(arr)
diff --git a/holoviews/operation/datashader.py b/holoviews/operation/datashader.py
index d4f548c00..d1fe2d1b7 100644
--- a/holoviews/operation/datashader.py
+++ b/holoviews/operation/datashader.py
@@ -2,6 +2,7 @@ import enum
 import warnings
 from collections.abc import Callable, Iterable
 from functools import partial
+from typing import TYPE_CHECKING
 
 import datashader as ds
 import datashader.reductions as rd
@@ -45,8 +46,8 @@ from ..core.util import (
     datetime_types,
     dt_to_int,
     get_param_values,
-    lazy_isinstance,
 )
+from ..core.util.dependencies import _LazyModule
 from ..element import (
     RGB,
     Area,
@@ -75,6 +76,12 @@ DATASHADER_VERSION = ds_version.release
 DATASHADER_GE_0_14_0 = DATASHADER_VERSION >= (0, 14, 0)
 DATASHADER_GE_0_15_1 = DATASHADER_VERSION >= (0, 15, 1)
 DATASHADER_GE_0_16_0 = DATASHADER_VERSION >= (0, 16, 0)
+DATASHADER_GE_0_18_1 = DATASHADER_VERSION >= (0, 18, 1)
+
+if TYPE_CHECKING:
+    import dask.dataframe as dd
+else:
+    dd = _LazyModule("dask.dataframe", bool_use_sys_modules=True)
 
 
 class AggregationOperation(ResampleOperation2D):
@@ -322,24 +329,24 @@ class aggregate(LineAggregationOperation):
         else:
             x, y = dims
 
+        bool_dd = bool(dd)  # NOTE: A lazy module and this avoids repeated lookups in `sys.modules`.
         if len(paths) > 1:
             if glyph == 'line':
                 path = paths[0][:1]
-                if lazy_isinstance(path, "dask.dataframe:DataFrame"):
+                if bool_dd and isinstance(path, dd.DataFrame):
                     path = path.compute()
                 empty = path.copy()
                 empty.iloc[0, :] = (np.nan,) * empty.shape[1]
                 paths = [elem for p in paths for elem in (p, empty)][:-1]
-            if all(lazy_isinstance(path,"dask.dataframe:DataFrame") for path in paths):
-                import dask.dataframe as dd
+            if bool_dd and all(isinstance(path, dd.DataFrame) for path in paths):
                 df = dd.concat(paths)
             else:
-                paths = [p.compute() if lazy_isinstance(p, "dask.dataframe:DataFrame") else p for p in paths]
+                paths = [p.compute() if bool_dd and isinstance(p, dd.DataFrame) else p for p in paths]
                 df = pd.concat(paths)
         else:
             df = paths[0] if paths else pd.DataFrame([], columns=[x.name, y.name])
 
-        is_custom = lazy_isinstance(df, "dask.dataframe:DataFrame") or cuDFInterface.applies(df)
+        is_custom = (bool_dd and isinstance(df, dd.DataFrame)) or cuDFInterface.applies(df)
         category_check = category and df[category].dtype.name != 'category'
         if (
             category_check or
@@ -417,6 +424,11 @@ class aggregate(LineAggregationOperation):
                 params["vdims"] = [params["vdims"]]
             sum_agg = ds.summary(**{str(params["vdims"][0]): agg_fn, "__index__": ds.where(sel_fn)})
             agg = self._apply_datashader(dfdata, cvs_fn, sum_agg, agg_kwargs, x, y, agg_state)
+            agg.attrs["selector"] = (
+                str(sel_fn)
+                if DATASHADER_GE_0_18_1
+                else f"{type(sel_fn).__name__}({getattr(sel_fn, 'column', '...')!r})"
+            )
         else:
             agg = self._apply_datashader(dfdata, cvs_fn, agg_fn, agg_kwargs, x, y, agg_state)
 
@@ -1448,6 +1460,7 @@ class shade(LinkableOperation):
             img_data = img_data.to_dataset(dim="band")
             img_data.update({k: sel_data[k] for k in sel_data.attrs["selector_columns"]})
             img_data.attrs["selector_columns"] = sel_data.attrs["selector_columns"]
+            img_data.attrs["selector"] = sel_data.attrs.get("selector")
         return img_data
 
 
diff --git a/holoviews/operation/timeseries.py b/holoviews/operation/timeseries.py
index f541fc486..e23aea920 100644
--- a/holoviews/operation/timeseries.py
+++ b/holoviews/operation/timeseries.py
@@ -1,5 +1,4 @@
 import numpy as np
-import pandas as pd
 import param
 
 from ..core import Element, Operation
@@ -110,6 +109,7 @@ class rolling_outlier_std(Operation, RollingBase):
         Minimum sigma before a value is considered an outlier.""")
 
     def _process_layer(self, element, key=None):
+        import pandas as pd
         ys = element.dimension_values(1)
 
         # Calculate the variation in the distribution of the residual
diff --git a/holoviews/plotting/bokeh/annotation.py b/holoviews/plotting/bokeh/annotation.py
index 46d94d8c5..3b870654f 100644
--- a/holoviews/plotting/bokeh/annotation.py
+++ b/holoviews/plotting/bokeh/annotation.py
@@ -3,7 +3,6 @@ from collections import defaultdict
 from html import escape
 
 import numpy as np
-import pandas as pd
 import param
 from bokeh.models import Arrow, BoxAnnotation, NormalHead, Slope, Span, TeeHead
 from bokeh.transform import dodge
@@ -73,6 +72,8 @@ class _SyntheticAnnotationPlot(ColorbarPlot):
         return figure
 
     def get_extents(self, element, ranges=None, range_type='combined', **kwargs):
+        import pandas as pd
+
         extents = super().get_extents(element, ranges, range_type)
         if isinstance(element, HLines):
             extents = np.nan, extents[0], np.nan, extents[2]
diff --git a/holoviews/plotting/bokeh/raster.py b/holoviews/plotting/bokeh/raster.py
index f36551c98..3f9a94339 100644
--- a/holoviews/plotting/bokeh/raster.py
+++ b/holoviews/plotting/bokeh/raster.py
@@ -22,9 +22,12 @@ from .util import BOKEH_GE_3_3_0, BOKEH_GE_3_4_0, BOKEH_GE_3_7_0, colormesh
 _EPOCH = np.datetime64("1970-01-01", "ns")
 
 
-class ServerHoverMixin:
+class ServerHoverMixin(param.Parameterized):
     _model_cache = {}
 
+    selector_in_hovertool = param.Boolean(default=True, doc="""
+        Whether to show the selector in HoverTool.""")
+
     def _update_hover(self, element):
         tool = self.handles['hover']
         if 'hv_created' in tool.tags and isinstance(tool.tooltips, Div):
@@ -54,8 +57,10 @@ class ServerHoverMixin:
         # Get dimensions
         coords, vars = list(data.coords), list(data.data_vars)
         vars.remove("__index__")
-        if ht := self.hover_tooltips:
+        ht = self.hover_tooltips or {}
+        if ht:
             ht = [ht] if isinstance(ht, str) else ht
+            ht = dict([t[::-1] if isinstance(t, tuple) else (t, t) for t in ht])
             coords = [c for c in coords if c in ht]
             vars = [v for v in vars if v in ht]
         elif isinstance(self, RGBPlot):
@@ -79,7 +84,7 @@ class ServerHoverMixin:
 
         hover_model = HoverModel()
         dtypes = {**data.coords.dtypes, **data.data_vars.dtypes}
-        is_datetime = [dtypes[c].kind == "M" for c in coords]
+        is_datetime = [dtypes[c].kind == "M" for c in data.coords]
         def _create_row(attr):
             kwargs = {}
             if BOKEH_GE_3_7_0:
@@ -90,11 +95,44 @@ class ServerHoverMixin:
                     kwargs["formatter"] = "datetime"
                     kwargs["format"] = "%Y-%m-%d %H:%M:%S"
             return (
-                Span(children=[f"{attr}:"], style={"color": "#26aae1", "text_align": "right"}),
+                Span(children=[f"{ht.get(attr, attr)}:"], style={"color": "#26aae1", "text_align": "right"}),
                 Span(children=[ValueOf(obj=hover_model, attr=attr, **kwargs)], style={"text_align": "left"}),
             )
+        children = [el for dim in dims for el in _create_row(dim)]
+
+        # Add a horizontal ruler and show the selector if available
+        selector_columns = data.attrs["selector_columns"]
+        first_selector = next((i for i, dim in enumerate(dims) if dim in selector_columns), None)
+        if first_selector:  # Don't show if first
+            divider = [Div(style={
+                "border": "none",
+                "height": "1px",
+                "background-color": "#ccc",
+                "margin": "4px 0",
+                "grid-column": "span 2",
+            })]
+        else:
+            divider = ()
+
+
+        if first_selector is not None and data.attrs.get("selector") and self.selector_in_hovertool:
+            selector_row = (
+                Span(children=["Selector:"], style={"color": "#26aae1", "font-weight": "bold", "text_align": "right"}),
+                Span(children=[data.attrs["selector"]], style={"font-weight": "bold", "text_align": "left"}),
+             )
+        else:
+            selector_row = ()
+
+        first_selector = first_selector or 0
+        children = [
+            *children[:first_selector * 2],
+            *divider,
+            *selector_row,
+            *children[first_selector * 2:],
+        ]
+
         style = Styles(display="grid", grid_template_columns="auto auto", column_gap="10px")
-        grid = Div(children=[el for dim in dims for el in _create_row(dim)], style=style)
+        grid = Div(children=children, style=style)
         hover.tooltips = grid
         hover.callback = CustomJS(
             args={"position": hover_model},
diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py
index 2399b395e..b7fe6480a 100644
--- a/holoviews/plotting/bokeh/util.py
+++ b/holoviews/plotting/bokeh/util.py
@@ -8,7 +8,6 @@ from itertools import permutations
 
 import bokeh
 import numpy as np
-import pandas as pd
 from bokeh.core.json_encoder import serialize_json  # noqa (API import)
 from bokeh.core.property.datetime import Datetime
 from bokeh.core.validation import silence
@@ -995,6 +994,7 @@ def date_to_integer(date):
     -------
     Milliseconds since 1970-01-01 00:00:00
     """
+    import pandas as pd
     if isinstance(date, pd.Timestamp):
         try:
             date = date.to_datetime64()
diff --git a/holoviews/plotting/mpl/annotation.py b/holoviews/plotting/mpl/annotation.py
index 066880094..a6bf10180 100644
--- a/holoviews/plotting/mpl/annotation.py
+++ b/holoviews/plotting/mpl/annotation.py
@@ -1,6 +1,5 @@
 import matplotlib as mpl
 import numpy as np
-import pandas as pd
 import param
 from matplotlib import patches
 from matplotlib.lines import Line2D
@@ -329,6 +328,7 @@ class _SyntheticAnnotationPlot(AnnotationPlot):
         return [fn(*val, **opts) for val in values]
 
     def get_extents(self, element, ranges=None, range_type='combined', **kwargs):
+        import pandas as pd
         extents = super().get_extents(element, ranges, range_type)
         if isinstance(element, HLines):
             extents = np.nan, extents[0], np.nan, extents[2]
diff --git a/holoviews/streams.py b/holoviews/streams.py
index a338f2d8a..233a4d4f2 100644
--- a/holoviews/streams.py
+++ b/holoviews/streams.py
@@ -11,16 +11,26 @@ from functools import partial
 from itertools import groupby
 from numbers import Number
 from types import FunctionType
+from typing import TYPE_CHECKING
 
 import numpy as np
-import pandas as pd
 import param
 
 from .core import util
 from .core.ndmapping import UniformNdMapping
 
+if TYPE_CHECKING:
+    import pandas as pd
+else:
+    pd = util.dependencies._LazyModule("pandas", bool_use_sys_modules=True)
+
+
 # Types supported by Pointer derived streams
-pointer_types = (Number, str, tuple, *util.datetime_types)
+@util.types.gen_types
+def pointer_types():
+    yield from (Number, str, tuple)
+    yield from util.datetime_types
+
 
 POPUP_POSITIONS = [
     "top_right",
@@ -549,7 +559,7 @@ class Buffer(Pipe):
         Arbitrary data being streamed to a DynamicMap callback.""")
 
     def __init__(self, data, length=1000, index=True, following=True, **params):
-        if isinstance(data, pd.DataFrame):
+        if pd and isinstance(data, pd.DataFrame):
             example = data
         elif isinstance(data, np.ndarray):
             if data.ndim != 2:
@@ -604,7 +614,7 @@ class Buffer(Pipe):
             elif x.shape[1] != self.data.shape[1]:
                 raise ValueError(f"Streamed array data expected to have {self.data.shape[1]} columns, "
                                  f"got {x.shape[1]}.")
-        elif isinstance(x, pd.DataFrame) and list(x.columns) != list(self.data.columns):
+        elif pd and isinstance(x, pd.DataFrame) and list(x.columns) != list(self.data.columns):
             raise IndexError(f"Input expected to have columns {list(self.data.columns)}, got {list(x.columns)}")
         elif isinstance(x, dict):
             if any(c not in x for c in self.data):
@@ -620,7 +630,7 @@ class Buffer(Pipe):
         """
         if isinstance(self.data, np.ndarray):
             data = self.data[:, :0]
-        elif isinstance(self.data, pd.DataFrame):
+        elif pd and isinstance(self.data, pd.DataFrame):
             data = self.data.iloc[:0]
         elif isinstance(self.data, dict):
             data = {k: v[:0] for k, v in self.data.items()}
@@ -643,7 +653,7 @@ class Buffer(Pipe):
                 data = np.concatenate([prev_chunk, data])
             elif data_length > self.length:
                 data = data[-self.length:]
-        elif isinstance(data, pd.DataFrame):
+        elif pd and isinstance(data, pd.DataFrame):
             data_length = len(data)
             if not self.length:
                 data = pd.concat([self.data, data])
diff --git a/holoviews/util/transform.py b/holoviews/util/transform.py
index 44d5d9e22..3400e905e 100644
--- a/holoviews/util/transform.py
+++ b/holoviews/util/transform.py
@@ -2,7 +2,6 @@ import operator
 from types import BuiltinFunctionType, BuiltinMethodType, FunctionType, MethodType
 
 import numpy as np
-import pandas as pd
 import param
 
 from ..core.data import PandasInterface
@@ -93,6 +92,7 @@ class iloc:
         return dim(self.expr, self)
 
     def __call__(self, values):
+        import pandas as pd
         if isinstance(values, (pd.Series, pd.DataFrame)):
             return values.iloc[resolve_dependent_value(self.index)]
         else:
@@ -905,6 +905,7 @@ class df_dim(dim):
     _accessor = 'pd'
 
     def __init__(self, obj, *args, **kwargs):
+        import pandas as pd
         super().__init__(obj, *args, **kwargs)
         self._ns = pd.Series
 


Test patch:
diff --git a/holoviews/tests/ui/bokeh/test_hover.py b/holoviews/tests/ui/bokeh/test_hover.py
index 4d0e86c9a..e6140b52e 100644
--- a/holoviews/tests/ui/bokeh/test_hover.py
+++ b/holoviews/tests/ui/bokeh/test_hover.py
@@ -266,6 +266,59 @@ def test_hover_tooltips_rasterize_server_hover(serve_hv, rng):
         expect(page.locator(".bk-Tooltip")).to_contain_text('cat:"-"')
 
 
+@pytest.mark.usefixtures("bokeh_backend")
+@pytest.mark.parametrize(
+    "hover_tooltips",
+    [None, ["x", "y"], ["x", "y", "s"], ["s"]],
+    ids=["default", "only_coords", "reduced_vars", "only_vars"],
+)
+@pytest.mark.parametrize("selector_in_hovertool", [True, False])
+def test_hover_tooltips_rasterize_server_hover_selector_ux(serve_hv, rng, hover_tooltips, selector_in_hovertool):
+    import datashader as ds
+
+    from holoviews.operation.datashader import rasterize
+
+    df = pd.DataFrame({
+        "x": rng.normal(45, 1, 100),
+        "y": rng.normal(85, 1, 100),
+        "s": 2,
+        "val": 10,
+        "cat": "cat1",
+    })
+    img = rasterize(hv.Points(df), selector=ds.first("val"))
+    img.opts(tools=["hover"], hover_tooltips=hover_tooltips, selector_in_hovertool=selector_in_hovertool)
+
+    page = serve_hv(img)
+    hv_plot = page.locator(".bk-events")
+    expect(hv_plot).to_have_count(1)
+    bbox = hv_plot.bounding_box()
+
+    # Hover over the plot, first time the hovertool only have null
+    # we then timeout and hover again to get hovertool with actual values
+    page.mouse.move(bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] / 2)
+    page.mouse.up()
+
+    expect(page.locator(".bk-Tooltip")).to_have_count(1)
+    page.wait_for_timeout(100)
+
+    page.mouse.move(bbox["x"] + bbox["width"] / 4, bbox["y"] + bbox["height"] / 4)
+    page.mouse.up()
+
+
+    # Selector test
+    if hover_tooltips == ["x", "y"] or not selector_in_hovertool:
+        expect(page.locator(".bk-Tooltip")).not_to_contain_text("Selector:first('val')")
+    else:
+        expect(page.locator(".bk-Tooltip")).to_contain_text("Selector:first('val')")
+
+    # The dividing line
+    line_expect = expect(page.locator("div[style*='height: 1px'][style*='grid-column: span 2']"))
+    if hover_tooltips in (["x", "y"], ["s"]):
+        line_expect.to_have_count(0)
+    else:
+        line_expect.to_have_count(1)
+
+
 @pytest.mark.usefixtures("bokeh_backend")
 @pytest.mark.parametrize("convert_x", [True, False])
 @pytest.mark.parametrize("convert_y", [True, False])
diff --git a/holoviews/tests/util/test_init.py b/holoviews/tests/util/test_init.py
index dc5b80fc2..d16564c70 100644
--- a/holoviews/tests/util/test_init.py
+++ b/holoviews/tests/util/test_init.py
@@ -8,7 +8,7 @@ def test_no_blocklist_imports():
     import sys
     import holoviews as hv
 
-    blocklist = {"panel", "IPython", "datashader", "ibis"}
+    blocklist = {"panel", "IPython", "datashader", "ibis", "pandas"}
     mods = blocklist & set(sys.modules)
 
     if mods: