Repository: marimo-team/marimo
Pull request: feat: support ibis native in mo.ui.table()
This adds an `IbisTableManager` instead of using converting it to a a `PyArrowTableManager`, otherwise the ibis table is no longer lazy.

This also has a few fixes for `str(ibis)` which when interactive mode was enabled, would be very expensive

Also adds a `limit=` field to `mo.ui.dataframe`, which fixes #2269

Problem statement:
Limit number of rows in mo.ui.dataframe as preview for Ibis
### Description

I am having a big dataset and want to filter it with mo.ui.dataframe, then all *illions of datapoints are loaded into the table.

### Suggested solution

would it be possible to add a limit(head in pandas) feature to the class marimo.ui.dataframe, which loads only, if a value is given, number of rows as a preview. But the resulting dataframe should be without the limit.

### Alternative

_No response_

### Additional context

_No response_

Hints from issue discussion:
@szst11 we only show 5 rows in `mo.ui.dataframe` and we only load that many in the frontend. Are you seeing something different? What version are you on?
@mscolnick right, only 5 rows are shown, as configurable with the page_size argument. But the full dataset seems to be available, when going to the other pages of the table and the length of the dataset is known. If using Ibis I also checked the query, where is no limit statement. (Version just updated to 0.8.14)
I think what you are seeing is that the full ibis table is being loaded into python memory, but not the frontend. 

I am guessing that is still not desirable? We do only load 5 at a time, but maybe by loading `total_rows` we are triggering a full load for ibis. 

Do you prefer the length to be unknown and not be able to jump to particular pages (just left/right)?
@mscolnick with the Ibis integration, you are now able to access and explore tables in big databases. So it would be necessary to avoid the loading of a full table, before filtering. The limit should be set in Python to the dataframe and then the length of the table would be the amount of available rows. Jumping pages would only be possible within that limited number of rows. Maybe a warning could be shown that a limit is active..
Got it - this makes sense. I can look into making Ibis more lazy, is it specifically retrieving the number of rows? or is there anything else that causes Ibis to load everything into memory?
If a datasource is large and some transformation are already in place, already a row count could need some resources on the database and may take a while. So I would suggest to limit the query after the applied transformations to get some of the expected results. But keep the output dataframe without the limit, as this should still be lazy. 
The limit behaviour might also be useful for Polars, as it can also handle large datasources in a lazy manner.

Commit message: feat: support ibis native in mo.ui.table()

Patch to review:
diff --git a/frontend/src/plugins/impl/data-frames/DataFramePlugin.tsx b/frontend/src/plugins/impl/data-frames/DataFramePlugin.tsx
index 50e97ccb61a..a7a5798795d 100644
--- a/frontend/src/plugins/impl/data-frames/DataFramePlugin.tsx
+++ b/frontend/src/plugins/impl/data-frames/DataFramePlugin.tsx
@@ -23,6 +23,7 @@ import type { DataType } from "../vega/vega-loader";
 import type { FieldTypesWithExternalType } from "@/components/data-table/types";
 import { Spinner } from "@/components/icons/spinner";
 import { ReadonlyCode } from "@/components/editor/code/readonly-python-code";
+import { isEqual } from "lodash-es";
 
 type CsvURL = string;
 type TableData<T> = T[] | CsvURL;
@@ -193,7 +194,7 @@ export const DataFrameComponent = memo(
       if (value?.transforms.length !== prevValue.transforms.length) {
         setValue(prevValue);
       }
-    }, [data, value?.transforms, prevValueRef, setValue]);
+    }, [data, value?.transforms.length, prevValueRef, setValue]);
 
     return (
       <div>
@@ -228,6 +229,10 @@ export const DataFrameComponent = memo(
               initialValue={internalValue}
               columns={columns}
               onChange={(v) => {
+                // Ignore changes that are the same
+                if (isEqual(v, internalValue)) {
+                  return;
+                }
                 // Update the value valid changes
                 setValue(v);
                 setInternalValue(v);
diff --git a/marimo/_messaging/ops.py b/marimo/_messaging/ops.py
index 1fb27f5aab7..4badfddc94c 100644
--- a/marimo/_messaging/ops.py
+++ b/marimo/_messaging/ops.py
@@ -39,6 +39,7 @@
 from marimo._plugins.core.json_encoder import WebComponentEncoder
 from marimo._plugins.core.web_component import JSONType
 from marimo._plugins.ui._core.ui_element import UIElement
+from marimo._plugins.ui._impl.tables.utils import get_table_manager_or_none
 from marimo._runtime.context import get_context
 from marimo._runtime.layout.layout import LayoutConfig
 from marimo._utils.platform import is_pyodide, is_windows
@@ -468,6 +469,14 @@ def __init__(
 
     def _stringify(self, value: object) -> str:
         try:
+            # HACK: We pretty-print tables to avoid str(ibis_table)
+            # which can be very slow when `ibis.options.interactive = True`
+            table_manager = get_table_manager_or_none(value)
+            if table_manager:
+                return str(table_manager)
+            else:
+                return str(value)[:50]
+
             return str(value)[:50]
         except BaseException:
             # Catch-all: some libraries like Polars have bugs and raise
diff --git a/marimo/_plugins/core/json_encoder.py b/marimo/_plugins/core/json_encoder.py
index 0049af8aeac..80c684ad69e 100644
--- a/marimo/_plugins/core/json_encoder.py
+++ b/marimo/_plugins/core/json_encoder.py
@@ -84,4 +84,7 @@ def default(self, o: Any) -> Any:
             return obj.isoformat()
 
         # Fallthrough to default encoder
-        return JSONEncoder.default(self, obj)
+        try:
+            return JSONEncoder.default(self, obj)
+        except TypeError:
+            return str(obj)
diff --git a/marimo/_plugins/ui/_impl/dataframes/dataframe.py b/marimo/_plugins/ui/_impl/dataframes/dataframe.py
index a4c4f84a5a8..425178ec87f 100644
--- a/marimo/_plugins/ui/_impl/dataframes/dataframe.py
+++ b/marimo/_plugins/ui/_impl/dataframes/dataframe.py
@@ -37,6 +37,7 @@
     get_table_manager,
 )
 from marimo._runtime.functions import EmptyArgs, Function
+from marimo._utils.memoize import memoize_last_value
 from marimo._utils.parse_dataclass import parse_raw
 
 
@@ -95,19 +96,19 @@ class dataframe(UIElement[Dict[str, Any], DataFrameType]):
 
     - `df`: the DataFrame or series to transform
     - `page_size`: the number of rows to show in the table
+    - `limit`: the number of items to load into memory, in case
+        the data is remote and lazily fetched. This is likely true
+        for SQL-backed dataframes via Ibis.
     """
 
     _name: Final[str] = "marimo-dataframe"
 
-    # Only get the first 100 (for performance reasons)
-    # Could make this configurable in the arguments later if desired.
-    DISPLAY_LIMIT = 100
-
     def __init__(
         self,
         df: DataFrameType,
         on_change: Optional[Callable[[DataFrameType], None]] = None,
         page_size: Optional[int] = 5,
+        limit: Optional[int] = None,
     ) -> None:
         # This will raise an error if the dataframe type is not supported.
         handler = get_handler_for_dataframe(df)
@@ -127,10 +128,11 @@ def __init__(
         except Exception:
             pass
 
+        self._limit = limit
         self._dataframe_name = dataframe_name
         self._data = df
         self._handler = handler
-        self._manager = get_table_manager(df)
+        self._manager = self._get_cached_table_manager(df, self._limit)
         self._transform_container = TransformsContainer[DataFrameType](
             df, handler
         )
@@ -148,7 +150,7 @@ def __init__(
             args={
                 "columns": self._get_column_types(),
                 "dataframe-name": dataframe_name,
-                "total": self._manager.get_num_rows(),
+                "total": self._manager.get_num_rows(force=False),
                 "page-size": page_size,
             },
             functions=(
@@ -180,7 +182,7 @@ def get_dataframe(self, _args: EmptyArgs) -> GetDataFrameResponse:
         if self._error is not None:
             raise GetDataFrameError(self._error)
 
-        manager = get_table_manager(self._value)
+        manager = self._get_cached_table_manager(self._value, self._limit)
         response = self.search(
             SearchTableArgs(page_size=self._page_size, page_number=0)
         )
@@ -194,7 +196,7 @@ def get_dataframe(self, _args: EmptyArgs) -> GetDataFrameResponse:
                 manager.get_column_names(),
                 self._last_transforms.transforms,
             ),
-            sql_code=self._handler.as_sql_code(self._value),
+            sql_code=self._handler.as_sql_code(manager.data),
         )
 
     def get_column_values(
@@ -241,16 +243,6 @@ def _convert_value(self, value: Dict[str, Any]) -> DataFrameType:
     def search(self, args: SearchTableArgs) -> SearchTableResponse:
         offset = args.page_number * args.page_size
 
-        # If no query or sort, return nothing
-        # The frontend will just show the original data
-        if not args.query and not args.sort and not args.filters:
-            manager = get_table_manager(self._value)
-            data = manager.take(args.page_size, offset).to_data()
-            return SearchTableResponse(
-                data=data,
-                total_rows=manager.get_num_rows(force=True) or 0,
-            )
-
         # Apply filters, query, and functools.sort using the cached method
         result = self._apply_filters_query_sort(
             args.query,
@@ -269,7 +261,7 @@ def _apply_filters_query_sort(
         query: Optional[str],
         sort: Optional[SortArgs],
     ) -> TableManager[Any]:
-        result = get_table_manager(self._value)
+        result = self._get_cached_table_manager(self._value, self._limit)
 
         if query:
             result = result.search(query)
@@ -278,3 +270,12 @@ def _apply_filters_query_sort(
             result = result.sort_values(sort.by, sort.descending)
 
         return result
+
+    @memoize_last_value
+    def _get_cached_table_manager(
+        self, value: Any, limit: Optional[int]
+    ) -> TableManager[Any]:
+        tm = get_table_manager(value)
+        if limit is not None:
+            tm = tm.take(limit, 0)
+        return tm
diff --git a/marimo/_plugins/ui/_impl/table.py b/marimo/_plugins/ui/_impl/table.py
index 60fef6c8c42..7c7c993999f 100644
--- a/marimo/_plugins/ui/_impl/table.py
+++ b/marimo/_plugins/ui/_impl/table.py
@@ -81,6 +81,7 @@ class SearchTableArgs:
     query: Optional[str] = None
     sort: Optional[SortArgs] = None
     filters: Optional[List[Condition]] = None
+    limit: Optional[int] = None
 
 
 @dataclass(frozen=True)
@@ -108,7 +109,9 @@ class table(
     1. a list of dicts, with one dict for each row, keyed by column names;
     2. a list of values, representing a table with a single column;
     3. a Pandas dataframe; or
-    4. a Polars dataframe.
+    4. a Polars dataframe; or
+    5. an Ibis dataframe; or
+    6. a PyArrow table.
 
     **Examples.**
 
@@ -296,7 +299,10 @@ def __init__(
             page_size = total_rows
         # pagination defaults to True if there are more than 10 rows
         if pagination is None:
-            pagination = total_rows == "too_many" or total_rows > 10
+            if total_rows == "too_many":
+                pagination = True
+            elif total_rows > 10:
+                pagination = True
 
         # Search first page
         search_result = self.search(
diff --git a/marimo/_plugins/ui/_impl/tables/ibis_table.py b/marimo/_plugins/ui/_impl/tables/ibis_table.py
new file mode 100644
index 00000000000..5ed075f7e9e
--- /dev/null
+++ b/marimo/_plugins/ui/_impl/tables/ibis_table.py
@@ -0,0 +1,218 @@
+# Copyright 2024 Marimo. All rights reserved.
+from __future__ import annotations
+
+from typing import Any, Optional, Tuple
+
+from marimo._data.models import (
+    ColumnSummary,
+    ExternalDataType,
+)
+from marimo._dependencies.dependencies import DependencyManager
+from marimo._plugins.ui._impl.tables.format import (
+    FormatMapping,
+)
+from marimo._plugins.ui._impl.tables.pandas_table import (
+    PandasTableManagerFactory,
+)
+from marimo._plugins.ui._impl.tables.polars_table import (
+    PolarsTableManagerFactory,
+)
+from marimo._plugins.ui._impl.tables.pyarrow_table import (
+    PyArrowTableManagerFactory,
+)
+from marimo._plugins.ui._impl.tables.table_manager import (
+    ColumnName,
+    FieldType,
+    FieldTypes,
+    TableManager,
+    TableManagerFactory,
+)
+from marimo._utils.memoize import memoize_last_value
+
+
+class IbisTableManagerFactory(TableManagerFactory):
+    @staticmethod
+    def package_name() -> str:
+        return "ibis"
+
+    @staticmethod
+    def create() -> type[TableManager[Any]]:
+        import ibis
+
+        class IbisTableManager(TableManager[ibis.Table]):
+            type = "ibis"
+
+            def to_csv(
+                self, format_mapping: Optional[FormatMapping] = None
+            ) -> bytes:
+                return self._as_table_manager().to_csv(format_mapping)
+
+            def to_json(self) -> bytes:
+                return self._as_table_manager().to_json()
+
+            def supports_download(self) -> bool:
+                return False
+
+            def apply_formatting(
+                self, format_mapping: FormatMapping
+            ) -> ibis.Table:
+                raise NotImplementedError("Column formatting not supported")
+
+            def supports_filters(self) -> bool:
+                return True
+
+            def select_rows(
+                self, indices: list[int]
+            ) -> TableManager[ibis.Table]:
+                if not indices:
+                    return self.take(0, 0)  # Return empty table
+                # Select rows using Ibis API
+                return IbisTableManager(
+                    self.data.filter(ibis.row_number().isin(indices))
+                )
+
+            def select_columns(
+                self, columns: list[str]
+            ) -> TableManager[ibis.Table]:
+                return IbisTableManager(self.data.select(columns))
+
+            def get_row_headers(
+                self,
+            ) -> list[str]:
+                return []
+
+            @staticmethod
+            def is_type(value: Any) -> bool:
+                return isinstance(value, ibis.Table)
+
+            def get_field_types(self) -> FieldTypes:
+                return {
+                    column: IbisTableManager._get_field_type(self.data[column])
+                    for column in self.data.columns
+                }
+
+            def take(self, count: int, offset: int) -> IbisTableManager:
+                if count < 0:
+                    raise ValueError("Count must be a positive integer")
+                if offset < 0:
+                    raise ValueError("Offset must be a non-negative integer")
+                return IbisTableManager(self.data.limit(count, offset=offset))
+
+            def search(self, query: str) -> TableManager[Any]:
+                query = query.lower()
+                predicates = []
+                for column in self.data.columns:
+                    col = self.data[column]
+                    if col.type().is_string():
+                        predicates.append(col.lower().contains(query))
+                    elif col.type().is_numeric():
+                        predicates.append(
+                            col.cast("string").lower().contains(query)
+                        )
+                    elif col.type().is_boolean():
+                        predicates.append(
+                            col.cast("string").lower().contains(query)
+                        )
+                    elif col.type().is_timestamp():
+                        predicates.append(
+                            col.cast("string").lower().contains(query)
+                        )
+                    elif col.type().is_date():
+                        predicates.append(
+                            col.cast("string").lower().contains(query)
+                        )
+                    elif col.type().is_time():
+                        predicates.append(
+                            col.cast("string").lower().contains(query)
+                        )
+
+                if predicates:
+                    filtered = self.data.filter(ibis.or_(*predicates))
+                else:
+                    filtered = self.data.filter(ibis.literal(False))
+
+                return IbisTableManager(filtered)
+
+            def get_summary(self, column: str) -> ColumnSummary:
+                col = self.data[column]
+                total = self.data.count().execute()
+                nulls = col.isnull().sum().execute()
+
+                summary = ColumnSummary(total=total, nulls=nulls)
+
+                if col.type().is_numeric():
+                    summary.min = col.min().execute()
+                    summary.max = col.max().execute()
+                    summary.mean = col.mean().execute()
+                    summary.median = col.median().execute()
+                    summary.std = col.std().execute()
+
+                return summary
+
+            @memoize_last_value
+            def get_num_rows(self, force: bool = True) -> Optional[int]:
+                if force:
+                    return self.data.count().execute()
+                return None
+
+            def get_num_columns(self) -> int:
+                return len(self.data.columns)
+
+            def get_column_names(self) -> list[str]:
+                return self.data.columns
+
+            def get_unique_column_values(
+                self, column: str
+            ) -> list[str | int | float]:
+                return (
+                    self.data.distinct(on=column)
+                    .select(column)
+                    .execute()[column]
+                    .tolist()
+                )
+
+            def sort_values(
+                self, by: ColumnName, descending: bool
+            ) -> IbisTableManager:
+                sorted_data = self.data.order_by(
+                    ibis.desc(by) if descending else ibis.asc(by)
+                )
+                return IbisTableManager(sorted_data)
+
+            @staticmethod
+            def _get_field_type(
+                column: ibis.expr.types.Column,
+            ) -> Tuple[FieldType, ExternalDataType]:
+                dtype = column.type()
+                if dtype.is_string():
+                    return ("string", str(dtype))
+                elif dtype.is_boolean():
+                    return ("boolean", str(dtype))
+                elif dtype.is_integer():
+                    return ("integer", str(dtype))
+                elif dtype.is_floating():
+                    return ("number", str(dtype))
+                elif dtype.is_timestamp():
+                    return ("date", str(dtype))
+                else:
+                    return ("unknown", str(dtype))
+
+            def _as_table_manager(self) -> TableManager[Any]:
+                if DependencyManager.pandas.has():
+                    return PandasTableManagerFactory.create()(
+                        self.data.to_pandas()
+                    )
+                if DependencyManager.polars.has():
+                    return PolarsTableManagerFactory.create()(
+                        self.data.to_polars()
+                    )
+                if DependencyManager.pyarrow.has():
+                    return PyArrowTableManagerFactory.create()(
+                        self.data.to_pyarrow()
+                    )
+
+                raise ValueError(
+                    "Requires at least one of pandas, polars, or pyarrow"
+                )
+
+        return IbisTableManager
diff --git a/marimo/_plugins/ui/_impl/tables/polars_table.py b/marimo/_plugins/ui/_impl/tables/polars_table.py
index 841bcb9b5cb..7a4c9be5e0c 100644
--- a/marimo/_plugins/ui/_impl/tables/polars_table.py
+++ b/marimo/_plugins/ui/_impl/tables/polars_table.py
@@ -185,6 +185,11 @@ def get_summary(self, column: str) -> ColumnSummary:
                         p75=col.quantile(0.75),
                         p95=col.quantile(0.95),
                     )
+                if col.dtype.is_(pl.Null):
+                    return ColumnSummary(
+                        total=total,
+                        nulls=col.null_count(),
+                    )
                 return ColumnSummary(
                     total=total,
                     nulls=col.null_count(),
diff --git a/marimo/_plugins/ui/_impl/tables/table_manager.py b/marimo/_plugins/ui/_impl/tables/table_manager.py
index cf9ff9968fd..bb0b3151cef 100644
--- a/marimo/_plugins/ui/_impl/tables/table_manager.py
+++ b/marimo/_plugins/ui/_impl/tables/table_manager.py
@@ -135,6 +135,13 @@ def get_column_names(self) -> list[str]:
     def get_unique_column_values(self, column: str) -> list[str | int | float]:
         raise NotImplementedError
 
+    def __repr__(self) -> str:
+        rows = self.get_num_rows(force=False)
+        columns = self.get_num_columns()
+        if rows is None:
+            return f"{self.type}: {columns:,} columns"
+        return f"{self.type}: {rows:,} rows x {columns:,} columns"
+
 
 class TableManagerFactory(abc.ABC):
     @staticmethod
diff --git a/marimo/_plugins/ui/_impl/tables/utils.py b/marimo/_plugins/ui/_impl/tables/utils.py
index f68a4ff9e90..e913a8647f8 100644
--- a/marimo/_plugins/ui/_impl/tables/utils.py
+++ b/marimo/_plugins/ui/_impl/tables/utils.py
@@ -8,6 +8,7 @@
 from marimo._plugins.ui._impl.tables.df_protocol_table import (
     DataFrameProtocolTableManager,
 )
+from marimo._plugins.ui._impl.tables.ibis_table import IbisTableManagerFactory
 from marimo._plugins.ui._impl.tables.pandas_table import (
     PandasTableManagerFactory,
 )
@@ -29,6 +30,7 @@
     PandasTableManagerFactory(),
     PolarsTableManagerFactory(),
     PyArrowTableManagerFactory(),
+    IbisTableManagerFactory(),
 ]
 
 
diff --git a/marimo/_smoke_tests/tables/ibis_example.py b/marimo/_smoke_tests/tables/ibis_example.py
new file mode 100644
index 00000000000..b8d84cb27a4
--- /dev/null
+++ b/marimo/_smoke_tests/tables/ibis_example.py
@@ -0,0 +1,24 @@
+import marimo
+
+__generated_with = "0.8.15"
+app = marimo.App(width="medium")
+
+
+@app.cell
+def __():
+    import marimo as mo
+    return mo,
+
+
+@app.cell
+def __(mo):
+    import ibis
+
+    table = ibis.memtable({"a": [1, 2, 3] * 1000, "b": [4, 5, 6] * 1000})
+
+    mo.ui.table(table)
+    return ibis, table
+
+
+if __name__ == "__main__":
+    app.run()
diff --git a/marimo/_smoke_tests/tables/ibis_interactive.py b/marimo/_smoke_tests/tables/ibis_interactive.py
new file mode 100644
index 00000000000..964fba4f97b
--- /dev/null
+++ b/marimo/_smoke_tests/tables/ibis_interactive.py
@@ -0,0 +1,92 @@
+import marimo
+
+__generated_with = "0.8.15"
+app = marimo.App(width="medium")
+
+
+@app.cell
+def __():
+    import marimo as mo
+    import polars as pl
+    import numpy as np
+    return mo, np, pl
+
+
+@app.cell
+def __(mo):
+    interactive = mo.ui.checkbox(label="Interactive", value=False)
+    interactive
+    return interactive,
+
+
+@app.cell
+def __(interactive):
+    import ibis
+
+    ibis.options.interactive = interactive.value
+    f"interactive: {ibis.options.interactive}"
+    return ibis,
+
+
+@app.cell
+def __(execution_timer, ibis):
+    # This should be 200-300ms (lazy)
+    with execution_timer("ibis.read_parquet"):
+        ibis_table = ibis.read_parquet(
+            "s3://gbif-open-data-us-east-1/occurrence/2023-04-01/occurrence.parquet/000000",
+        )
+    return ibis_table,
+
+
+@app.cell
+def __(execution_timer, ibis_table):
+    # Takes a while, loads data into memory when `ibis.options.interactive = True`
+    with execution_timer("ibis_table"):
+        print(ibis_table)
+    return
+
+
+@app.cell
+def __(ibis_table):
+    # Takes a while, loads data into memory when `ibis.options.interactive = True`
+    ibis_table
+    return
+
+
+@app.cell
+def __(execution_timer, ibis_table):
+    # This should be fast (lazy)
+    with execution_timer("t.head(10)"):
+        ibis_head = ibis_table.head(10)
+    return ibis_head,
+
+
+@app.cell(hide_code=True)
+def __():
+    import time
+
+
+    class execution_timer:
+        def __init__(self, name):
+            self.name = name
+
+        def __enter__(self):
+            self.start_time = time.time()
+            return self
+
+        def __exit__(self, exc_type, exc_value, traceback):
+            self.end_time = time.time()
+            duration = self.end_time - self.start_time
+            if duration < 0.050:  # 50ms
+                print(
+                    f"\033[92m[FAST]\033[0m {self.name} time: {duration} seconds"
+                )
+            else:
+                print(
+                    f"\033[91m[SLOW]\033[0m {self.name} time: {duration} seconds"
+                )
+    return execution_timer, time
+
+
+if __name__ == "__main__":
+    app.run()
diff --git a/marimo/_smoke_tests/tables/lazy.py b/marimo/_smoke_tests/tables/lazy.py
new file mode 100644
index 00000000000..217664e62ae
--- /dev/null
+++ b/marimo/_smoke_tests/tables/lazy.py
@@ -0,0 +1,193 @@
+import marimo
+
+__generated_with = "0.8.15"
+app = marimo.App(width="medium")
+
+
+@app.cell
+def __():
+    import marimo as mo
+    import polars as pl
+    import numpy as np
+    return mo, np, pl
+
+
+@app.cell
+def __(mo):
+    mo.md(r"""# Lazy polars""")
+    return
+
+
+@app.cell
+def __(execution_timer, np, pl):
+    lazy_df = pl.LazyFrame(
+        {
+            "A": np.random.randint(0, 100, size=100000000),
+            "B": np.random.rand(100000000),
+        }
+    )
+    with execution_timer("print(lazy_df)"):
+        print(lazy_df)
+    lazy_df
+    return lazy_df,
+
+
+@app.cell
+def __(lazy_df):
+    polars_df = lazy_df.collect()
+    polars_df
+    return polars_df,
+
+
+@app.cell
+def __(mo):
+    mo.md(r"""# Lazy ibis""")
+    return
+
+
+@app.cell
+def __():
+    import ibis
+    return ibis,
+
+
+@app.cell
+def __(execution_timer, ibis):
+    # This should be 200-300ms (lazy)
+    with execution_timer("ibis.read_parquet"):
+        ibis_table = ibis.read_parquet(
+            "s3://gbif-open-data-us-east-1/occurrence/2023-04-01/occurrence.parquet/000000",
+        )
+    return ibis_table,
+
+
+@app.cell
+def __(execution_timer, ibis, ibis_table, mo):
+    # This should be slow, needs to load to print
+    ibis.options.interactive = False
+    with execution_timer("as_html, interactive = False"):
+        mo.output.replace(ibis_table)
+    return
+
+
+@app.cell
+def __(execution_timer, ibis, ibis_table, mo):
+    # This should be slow, needs to load to print
+    ibis.options.interactive = True
+    with execution_timer("as_html, interactive = True"):
+        mo.output.replace(ibis_table)
+    ibis.options.interactive = False
+    return
+
+
+@app.cell
+def __(execution_timer, ibis_table):
+    # This should be fast (lazy)
+    with execution_timer("t.head(10)"):
+        _private_var = ibis_table.head(10)
+
+    # This should be fast (lazy)
+    with execution_timer("t.head(10)"):
+        ibis_head = ibis_table.head(10)
+
+    # This should be ~300-500ms
+    with execution_timer("t.count().execute()"):
+        count = ibis_table.count().execute()
+    return count, ibis_head
+
+
+@app.cell
+def __(execution_timer, ibis):
+    def time_things(data):
+        # This should be fast
+        with execution_timer("type"):
+            print(type(data))
+
+        from marimo._plugins.ui._impl.tables.utils import get_table_manager
+
+        with execution_timer("get_table_manager"):
+            tm = get_table_manager(data)
+            print(tm)
+
+        with execution_timer("get_num_rows(force=False)"):
+            _res = tm.get_num_rows(force=False)
+
+        with execution_timer("get_num_rows(force=True)"):
+            _res = tm.get_num_rows(force=True)
+
+        with execution_timer("get_column_names"):
+            _res = tm.get_column_names()
+
+        with execution_timer("get_field_types"):
+            _res = tm.get_field_types()
+
+        with execution_timer("take"):
+            _res = tm.take(100000, 0)
+
+        with execution_timer("to_sql"):
+            if "ibis" in str(type(tm)).lower():
+                ibis.to_sql(tm.take(100, 0).take(10, 0).data.count())
+    return time_things,
+
+
+@app.cell
+def __(ibis_table, time_things):
+    # Ibis
+    time_things(ibis_table)
+    return
+
+
+@app.cell
+def __(polars_df, time_things):
+    # Polars
+    time_things(polars_df)
+    return
+
+
+@app.cell
+def __(execution_timer, ibis_table, mo):
+    # This takes a while (runs a count(*) with no limit)
+    with execution_timer("mo.ui.dataframe"):
+        _df_viewer = mo.ui.dataframe(ibis_table)
+    _df_viewer
+    return
+
+
+@app.cell
+def __(execution_timer, ibis_table, mo):
+    # This takes a while (runs a count(*) with no limit)
+    with execution_timer("mo.ui.dataframe(limit=10)"):
+        _df_viewer = mo.ui.dataframe(ibis_table, limit=10)
+    _df_viewer
+    return
+
+
+@app.cell(hide_code=True)
+def __():
+    import time
+
+
+    class execution_timer:
+        def __init__(self, name):
+            self.name = name
+
+        def __enter__(self):
+            self.start_time = time.time()
+            return self
+
+        def __exit__(self, exc_type, exc_value, traceback):
+            self.end_time = time.time()
+            duration = self.end_time - self.start_time
+            if duration < 0.050:  # 50ms
+                print(
+                    f"\033[92m[FAST]\033[0m {self.name} time: {duration} seconds"
+                )
+            else:
+                print(
+                    f"\033[91m[SLOW]\033[0m {self.name} time: {duration} seconds"
+                )
+    return execution_timer, time
+
+
+if __name__ == "__main__":
+    app.run()
diff --git a/marimo/_utils/memoize.py b/marimo/_utils/memoize.py
new file mode 100644
index 00000000000..8028c9df8e7
--- /dev/null
+++ b/marimo/_utils/memoize.py
@@ -0,0 +1,35 @@
+from __future__ import annotations
+
+from typing import Any, Callable, Tuple, TypeVar, cast
+
+T = TypeVar("T")
+
+sentinel = object()  # Unique sentinel object
+
+
+def memoize_last_value(func: Callable[..., T]) -> Callable[..., T]:
+    last_input: Tuple[Tuple[Any, ...], frozenset[Tuple[str, Any]]] | None = (
+        None
+    )
+    last_output: T = cast(T, sentinel)
+
+    def wrapper(*args: Any, **kwargs: Any) -> T:
+        nonlocal last_input, last_output
+
+        current_input: Tuple[Tuple[Any, ...], frozenset[Tuple[str, Any]]] = (
+            args,
+            frozenset(kwargs.items()),
+        )
+
+        if current_input is last_input:
+            assert last_output is not sentinel
+            return last_output
+
+        result: T = func(*args, **kwargs)
+
+        last_input = current_input
+        last_output = result
+
+        return result
+
+    return wrapper
diff --git a/tests/_plugins/ui/_impl/dataframes/test_dataframe.py b/tests/_plugins/ui/_impl/dataframes/test_dataframe.py
index b71c3414590..02e4a984fef 100644
--- a/tests/_plugins/ui/_impl/dataframes/test_dataframe.py
+++ b/tests/_plugins/ui/_impl/dataframes/test_dataframe.py
@@ -131,3 +131,94 @@ def test_dataframe_page_size(
         )
         assert search_result.total_rows == 3
         assert search_result.data == result.url
+
+    @staticmethod
+    @pytest.mark.parametrize(
+        "df",
+        [
+            pd.DataFrame({"A": [], "B": []}),  # Empty DataFrame
+            pd.DataFrame({"A": [1], "B": ["a"]}),  # Single row DataFrame
+            pd.DataFrame(
+                {
+                    "A": list(range(1, 1001)),
+                    "B": [f"value_{i}" for i in range(1, 1001)],
+                }
+            ),  # Large DataFrame
+        ],
+    )
+    def test_dataframe_edge_cases(df: Any) -> None:
+        subject = ui.dataframe(df)
+
+        assert subject.value is df
+        assert len(subject._component_args["columns"]) == 2
+
+        result = subject.get_dataframe(EmptyArgs())
+        assert result.total_rows == len(df)
+
+        # Test get_column_values for empty and large DataFrames
+        if len(df) == 0:
+            assert subject.get_column_values(
+                GetColumnValuesArgs(column="A")
+            ) == GetColumnValuesResponse(values=[], too_many_values=False)
+        elif len(df) >= 1000:
+            response = subject.get_column_values(
+                GetColumnValuesArgs(column="A")
+            )
+            assert response.too_many_values is True
+            assert len(response.values) == 0
+
+    @staticmethod
+    def test_dataframe_with_custom_page_size() -> None:
+        df = pd.DataFrame({"A": range(100), "B": ["a"] * 100})
+        subject = ui.dataframe(df, page_size=10)
+
+        result = subject.get_dataframe(EmptyArgs())
+        assert result.total_rows == 100
+
+        search_result = subject.search(
+            SearchTableArgs(page_size=10, page_number=0)
+        )
+        assert search_result.total_rows == 100
+        assert search_result.data == result.url
+
+    @staticmethod
+    def test_dataframe_with_non_string_column_names() -> None:
+        df = pd.DataFrame(
+            {0: [1, 2, 3], 1.5: ["a", "b", "c"], "2": [True, False, True]}
+        )
+        subject = ui.dataframe(df)
+
+        assert subject.value is df
+        assert len(subject._component_args["columns"]) == 3
+
+        # Test that we can get column values for non-string column names
+        assert subject.get_column_values(
+            GetColumnValuesArgs(column=0)
+        ) == GetColumnValuesResponse(values=[1, 2, 3], too_many_values=False)
+        assert subject.get_column_values(
+            GetColumnValuesArgs(column=1.5)
+        ) == GetColumnValuesResponse(
+            values=["a", "b", "c"], too_many_values=False
+        )
+
+    @staticmethod
+    def test_dataframe_with_limit() -> None:
+        df = pd.DataFrame({"A": range(1000), "B": ["a"] * 1000})
+        subject = ui.dataframe(df, limit=100)
+
+        result = subject.get_dataframe(EmptyArgs())
+        assert result.total_rows == 100
+
+        search_result = subject.search(
+            SearchTableArgs(page_size=10, page_number=0)
+        )
+        assert search_result.total_rows == 100
+
+    @staticmethod
+    def test_dataframe_error_handling() -> None:
+        df = pd.DataFrame({"A": [1, 2, 3], "B": ["a", "b", "c"]})
+        subject = ui.dataframe(df)
+
+        # Test ColumnNotFound error
+        with pytest.raises(ColumnNotFound):
+            subject.get_column_values(GetColumnValuesArgs(column="C"))
diff --git a/tests/_plugins/ui/_impl/tables/snapshots/ibis.csv b/tests/_plugins/ui/_impl/tables/snapshots/ibis.csv
new file mode 100644
index 00000000000..56096ef045d
--- /dev/null
+++ b/tests/_plugins/ui/_impl/tables/snapshots/ibis.csv
@@ -0,0 +1,4 @@
+strings,bool,int,float,datetime,nulls
+a,True,1,1.0,2021-01-01,
+b,False,2,2.0,2021-01-02,data
+c,True,3,3.0,2021-01-03,
diff --git a/tests/_plugins/ui/_impl/tables/snapshots/ibis.field_types.json b/tests/_plugins/ui/_impl/tables/snapshots/ibis.field_types.json
new file mode 100644
index 00000000000..e7957884861
--- /dev/null
+++ b/tests/_plugins/ui/_impl/tables/snapshots/ibis.field_types.json
@@ -0,0 +1,8 @@
+{
+  "strings": ["string", "string"],
+  "bool": ["boolean", "boolean"],
+  "int": ["integer", "int64"],
+  "float": ["number", "float64"],
+  "datetime": ["date", "timestamp"],
+  "nulls": ["string", "string"]
+}
diff --git a/tests/_plugins/ui/_impl/tables/snapshots/ibis.json b/tests/_plugins/ui/_impl/tables/snapshots/ibis.json
new file mode 100644
index 00000000000..c10a5114d80
--- /dev/null
+++ b/tests/_plugins/ui/_impl/tables/snapshots/ibis.json
@@ -0,0 +1,26 @@
+[
+  {
+    "strings": "a",
+    "bool": true,
+    "int": 1,
+    "float": 1.0,
+    "datetime": 1609459200,
+    "nulls": null
+  },
+  {
+    "strings": "b",
+    "bool": false,
+    "int": 2,
+    "float": 2.0,
+    "datetime": 1609545600,
+    "nulls": "data"
+  },
+  {
+    "strings": "c",
+    "bool": true,
+    "int": 3,
+    "float": 3.0,
+    "datetime": 1609632000,
+    "nulls": null
+  }
+]
diff --git a/tests/_plugins/ui/_impl/tables/test_ibis_table.py b/tests/_plugins/ui/_impl/tables/test_ibis_table.py
new file mode 100644
index 00000000000..a7507f9ea12
--- /dev/null
+++ b/tests/_plugins/ui/_impl/tables/test_ibis_table.py
@@ -0,0 +1,263 @@
+from __future__ import annotations
+
+import datetime
+import json
+import unittest
+from typing import Any
+
+import pytest
+
+from marimo._data.models import ColumnSummary
+from marimo._dependencies.dependencies import DependencyManager
+from marimo._plugins.ui._impl.tables.ibis_table import (
+    IbisTableManagerFactory,
+)
+from marimo._plugins.ui._impl.tables.table_manager import TableManager
+from tests.mocks import snapshotter
+
+HAS_DEPS = DependencyManager.ibis.has()
+
+snapshot = snapshotter(__file__)
+
+
+@pytest.mark.skipif(not HAS_DEPS, reason="optional dependencies not installed")
+class TestIbisTableManagerFactory(unittest.TestCase):
+    def get_complex_data(self) -> TableManager[Any]:
+        import ibis
+
+        complex_data = ibis.memtable(
+            {
+                "strings": ["a", "b", "c"],
+                "bool": [True, False, True],
+                "int": [1, 2, 3],
+                "float": [1.0, 2.0, 3.0],
+                "datetime": [
+                    datetime.datetime(2021, 1, 1),
+                    datetime.datetime(2021, 1, 2),
+                    datetime.datetime(2021, 1, 3),
+                ],
+                "nulls": [None, "data", None],
+            }
+        )
+        return self.factory.create()(complex_data)
+
+    def setUp(self) -> None:
+        import ibis
+
+        self.factory = IbisTableManagerFactory()
+        self.data = ibis.memtable(
+            {
+                "A": [1, 2, 3],
+                "B": ["a", "b", "c"],
+                "C": [1.0, 2.0, 3.0],
+                "D": [True, False, True],
+                "E": [
+                    datetime.datetime(2021, 1, 1),
+                    datetime.datetime(2021, 1, 2),
+                    datetime.datetime(2021, 1, 3),
+                ],
+            }
+        )
+        self.manager = self.factory.create()(self.data)
+
+    def test_package_name(self) -> None:
+        assert self.factory.package_name() == "ibis"
+
+    def test_to_csv(self) -> None:
+        assert isinstance(self.manager.to_csv(), bytes)
+
+        complex_data = self.get_complex_data()
+        data = complex_data.to_csv()
+        assert isinstance(data, bytes)
+        snapshot("ibis.csv", data.decode("utf-8"))
+
+    def test_to_json(self) -> None:
+        assert isinstance(self.manager.to_json(), bytes)
+
+        complex_data = self.get_complex_data()
+        data = complex_data.to_json()
+        assert isinstance(data, bytes)
+        snapshot("ibis.json", data.decode("utf-8"))
+
+    def test_complex_data_field_types(self) -> None:
+        complex_data = self.get_complex_data()
+        field_types = complex_data.get_field_types()
+        snapshot("ibis.field_types.json", json.dumps(field_types))
+
+    def test_select_rows(self) -> None:
+        import ibis
+
+        indices = [0, 2]
+        selected_manager = self.manager.select_rows(indices)
+        expected_data = self.data.filter(ibis.row_number().isin(indices))
+        assert selected_manager.data.to_pandas().equals(
+            expected_data.to_pandas()
+        )
+
+    def test_select_columns(self) -> None:
+        columns = ["A"]
+        selected_manager = self.manager.select_columns(columns)
+        expected_data = self.data.select(columns)
+        assert selected_manager.data.to_pandas().equals(
+            expected_data.to_pandas()
+        )
+
+    def test_get_row_headers(self) -> None:
+        expected_headers = []
+        assert self.manager.get_row_headers() == expected_headers
+
+    def test_is_type(self) -> None:
+        assert self.manager.is_type(self.data)
+        assert not self.manager.is_type("not a table")
+
+    def test_get_field_types(self) -> None:
+        expected_field_types = {
+            "A": ("integer", "int64"),
+            "B": ("string", "string"),
+            "C": ("number", "float64"),
+            "D": ("boolean", "boolean"),
+            "E": ("date", "timestamp"),
+        }
+        assert self.manager.get_field_types() == expected_field_types
+
+    def test_limit(self) -> None:
+        limited_manager = self.manager.take(1, 0)
+        expected_data = self.data.limit(1)
+        assert limited_manager.data.to_pandas().equals(
+            expected_data.to_pandas()
+        )
+
+    def test_summary_integer(self) -> None:
+        column = "A"
+        summary = self.manager.get_summary(column)
+        assert summary == ColumnSummary(
+            total=3,
+            nulls=0,
+            min=1,
+            max=3,
+            mean=2.0,
+            median=2.0,
+            std=1.0,
+        )
+
+    def test_summary_string(self) -> None:
+        column = "B"
+        summary = self.manager.get_summary(column)
+        assert summary == ColumnSummary(
+            total=3,
+            nulls=0,
+        )
+
+    def test_sort_values(self) -> None:
+        import ibis
+
+        sorted_manager = self.manager.sort_values("A", descending=True)
+        expected_df = self.data.order_by(ibis.desc("A"))
+        assert sorted_manager.data.to_pandas().equals(expected_df.to_pandas())
+
+    def test_get_unique_column_values(self) -> None:
+        column = "A"
+        unique_values = self.manager.get_unique_column_values(column)
+        assert sorted(unique_values) == [1, 2, 3]
+
+    def test_search(self) -> None:
+        import ibis
+
+        df = ibis.memtable(
+            {
+                "A": [1, 2, 3],
+                "B": ["foo", "bar", "baz"],
+                "C": [True, False, True],
+            }
+        )
+        manager = self.factory.create()(df)
+        assert manager.search("foo").get_num_rows() == 1
+        assert manager.search("a").get_num_rows() == 2
+        assert manager.search("true").get_num_rows() == 2
+        assert manager.search("food").get_num_rows() == 0
+
+    @pytest.mark.skip
+    def test_apply_formatting(self) -> None:
+        import ibis
+
+        from marimo._plugins.ui._impl.tables.format import FormatMapping
+
+        format_mapping: FormatMapping = {
+            "A": lambda x: x * 2,
+            "B": lambda x: x.upper(),
+            "C": lambda x: f"{x:.2f}",
+            "D": lambda x: not x,
+            "E": lambda x: x.strftime("%Y-%m-%d"),
+        }
+
+        formatted_data = self.manager.apply_formatting(format_mapping)
+        expected_data = ibis.memtable(
+            {
+                "A": [2, 4, 6],
+                "B": ["A", "B", "C"],
+                "C": ["1.00", "2.00", "3.00"],
+                "D": [False, True, False],
+                "E": ["2021-01-01", "2021-01-02", "2021-01-03"],
+            }
+        )
+        assert formatted_data.to_pandas().equals(expected_data.to_pandas())
+
+    def test_empty_table(self) -> None:
+        import ibis
+
+        empty_table = ibis.memtable({"A": []})
+        empty_manager = self.factory.create()(empty_table)
+        assert empty_manager.get_num_rows() == 0
+
+    def test_table_with_all_null_column(self) -> None:
+        import ibis
+
+        table = ibis.memtable({"A": [1, 2, 3], "B": [None, None, None]})
+        manager = self.factory.create()(table)
+        summary = manager.get_summary("B")
+        assert summary.nulls == 3
+        assert summary.total == 3
+
+    def test_table_with_mixed_types(self) -> None:
+        import ibis
+
+        table = ibis.memtable({"A": [1, "two", 3.0, True]})
+        manager = self.factory.create()(table)
+        field_types = manager.get_field_types()
+        assert field_types["A"] == ("unknown", "unknown")
+
+    @pytest.mark.skip
+    def test_search_with_regex(self) -> None:
+        import ibis
+
+        table = ibis.memtable({"A": ["apple", "banana", "cherry"]})
+        manager = self.factory.create()(table)
+        result = manager.search("^[ab]")
+        assert result.get_num_rows() == 2
+
+    def test_sort_values_with_nulls(self) -> None:
+        import ibis
+        import numpy as np
+
+        table = ibis.memtable({"A": [3, 1, None, 2]})
+        manager = self.factory.create()(table)
+
+        # Descending true
+        sorted_manager = manager.sort_values("A", descending=True)
+        sorted_data = sorted_manager.data.to_pandas()["A"].tolist()
+        assert sorted_data[0:3] == [
+            3.0,
+            2.0,
+            1.0,
+        ]
+        assert np.isnan(sorted_data[3])
+
+        # Descending false
+        sorted_manager = manager.sort_values("A", descending=False)
+        sorted_data = sorted_manager.data.to_pandas()["A"].tolist()
+        assert sorted_data[0:3] == [
+            1.0,
+            2.0,
+            3.0,
+        ]
+        assert np.isnan(sorted_data[3])
diff --git a/tests/_plugins/ui/_impl/tables/test_pandas_table.py b/tests/_plugins/ui/_impl/tables/test_pandas_table.py
index f6bbb5f7dc0..4bc77838d74 100644
--- a/tests/_plugins/ui/_impl/tables/test_pandas_table.py
+++ b/tests/_plugins/ui/_impl/tables/test_pandas_table.py
@@ -686,3 +686,58 @@ def test_apply_formatting_with_complex_data(self) -> None:
             }
         )
         pd.testing.assert_frame_equal(formatted_data, expected_data)
+
+    def test_empty_dataframe(self) -> None:
+        import pandas as pd
+
+        empty_df = pd.DataFrame()
+        empty_manager = self.factory.create()(empty_df)
+        assert empty_manager.get_num_rows() == 0
+        assert empty_manager.get_num_columns() == 0
+        assert empty_manager.get_column_names() == []
+        assert empty_manager.get_field_types() == {}
+
+    def test_dataframe_with_all_null_column(self) -> None:
+        import pandas as pd
+
+        df = pd.DataFrame({"A": [1, 2, 3], "B": [None, None, None]})
+        manager = self.factory.create()(df)
+        summary = manager.get_summary("B")
+        assert summary.nulls == 3
+        assert summary.total == 0
+
+    def test_dataframe_with_mixed_types(self) -> None:
+        import pandas as pd
+
+        df = pd.DataFrame({"A": [1, "two", 3.0, True]})
+        manager = self.factory.create()(df)
+        field_types = manager.get_field_types()
+        assert field_types["A"] == ("string", "object")
+
+    @pytest.mark.skip
+    def test_search_with_regex(self) -> None:
+        import pandas as pd
+
+        df = pd.DataFrame({"A": ["apple", "banana", "cherry"]})
+        manager = self.factory.create()(df)
+        result = manager.search("^[ab]")
+        assert result.get_num_rows() == 2
+
+    def test_sort_values_with_nulls(self) -> None:
+        import pandas as pd
+
+        df = pd.DataFrame({"A": [3, 1, None, 2]})
+        manager = self.factory.create()(df)
+        sorted_manager = manager.sort_values("A", descending=True)
+        assert sorted_manager.data["A"].tolist()[0:3] == [3.0, 2.0, 1.0]
+
+    def test_dataframe_with_multiindex(self) -> None:
+        import pandas as pd
+
+        df = pd.DataFrame(
+            {"A": [1, 2, 3, 4], "B": [5, 6, 7, 8]},
+            index=[["a", "a", "b", "b"], [1, 2, 1, 2]],
+        )
+        manager = self.factory.create()(df)
+        assert manager.get_row_headers() == ["", ""]
+        assert manager.get_num_rows() == 4
diff --git a/tests/_plugins/ui/_impl/tables/test_polars_table.py b/tests/_plugins/ui/_impl/tables/test_polars_table.py
index 9105716fea0..cb5ef52e16d 100644
--- a/tests/_plugins/ui/_impl/tables/test_polars_table.py
+++ b/tests/_plugins/ui/_impl/tables/test_polars_table.py
@@ -590,3 +590,47 @@ def test_apply_formatting_with_complex_data(self) -> None:
             }
         )
         assert_frame_equal(formatted_data, expected_data)
+
+    def test_empty_dataframe(self) -> None:
+        import polars as pl
+
+        empty_df = pl.DataFrame()
+        empty_manager = self.factory.create()(empty_df)
+        assert empty_manager.get_num_rows() == 0
+        assert empty_manager.get_num_columns() == 0
+        assert empty_manager.get_column_names() == []
+        assert empty_manager.get_field_types() == {}
+
+    def test_dataframe_with_all_null_column(self) -> None:
+        import polars as pl
+
+        df = pl.DataFrame({"A": [1, 2, 3], "B": [None, None, None]})
+        manager = self.factory.create()(df)
+        summary = manager.get_summary("B")
+        assert summary.nulls == 3
+        assert summary.total == 3
+
+    def test_dataframe_with_mixed_types(self) -> None:
+        import polars as pl
+
+        df = pl.DataFrame({"A": [1, "two", 3.0, True]}, strict=False)
+        manager = self.factory.create()(df)
+        field_types = manager.get_field_types()
+        assert field_types["A"] == ("string", "str")
+
+    @pytest.mark.skip
+    def test_search_with_regex(self) -> None:
+        import polars as pl
+
+        df = pl.DataFrame({"A": ["apple", "banana", "cherry"]})
+        manager = self.factory.create()(df)
+        result = manager.search("^[ab]")
+        assert result.get_num_rows() == 2
+
+    def test_sort_values_with_nulls(self) -> None:
+        import polars as pl
+
+        df = pl.DataFrame({"A": [3, 1, None, 2]})
+        manager = self.factory.create()(df)
+        sorted_manager = manager.sort_values("A", descending=True)
+        assert sorted_manager.data["A"].to_list() == [None, 3, 2, 1]
diff --git a/tests/_utils/test_memoize.py b/tests/_utils/test_memoize.py
new file mode 100644
index 00000000000..9f839d4ce2a
--- /dev/null
+++ b/tests/_utils/test_memoize.py
@@ -0,0 +1,148 @@
+from __future__ import annotations
+
+from typing import Any
+
+from marimo._utils.memoize import memoize_last_value
+
+
+def test_memoization_with_same_args() -> None:
+    call_count = 0
+
+    @memoize_last_value
+    def test_func(x: int, y: int) -> int:
+        nonlocal call_count
+        call_count += 1
+        return x + y
+
+    # First call
+    result1 = test_func(2, 3)
+    assert result1 == 5
+    assert call_count == 1
+
+    # Second call with same args
+    result2 = test_func(2, 3)
+    assert result2 == 5
+    assert call_count == 1  # Should not increase
+
+
+def test_memoization_with_different_args() -> None:
+    call_count = 0
+
+    @memoize_last_value
+    def test_func(x, y):
+        nonlocal call_count
+        call_count += 1
+        return x + y
+
+    result1 = test_func(2, 3)
+    assert result1 == 5
+    assert call_count == 1
+
+    result2 = test_func(3, 4)
+    assert result2 == 7
+    assert call_count == 2
+
+
+def test_memoization_with_kwargs() -> None:
+    call_count = 0
+
+    @memoize_last_value
+    def test_func(x: int, y: int, z: int = 0) -> int:
+        nonlocal call_count
+        call_count += 1
+        return x + y + z
+
+    result1 = test_func(2, 3, z=1)
+    assert result1 == 6
+    assert call_count == 1
+
+    result2 = test_func(2, 3, z=1)
+    assert result2 == 6
+    assert call_count == 1  # Should not increase
+
+    result3 = test_func(2, 3, z=2)
+    assert result3 == 7
+    assert call_count == 2
+
+
+def test_memoization_with_mutable_args() -> None:
+    call_count = 0
+
+    @memoize_last_value
+    def test_func(x: list[int], y: int) -> list[int]:
+        nonlocal call_count
+        call_count += 1
+        return x + [y]
+
+    list1 = [1, 2, 3]
+    result1 = test_func(list1, 4)
+    assert result1 == [1, 2, 3, 4]
+    assert call_count == 1
+
+    # Modifying the list should not affect memoization
+    list1.append(4)
+    result2 = test_func(list1, 4)
+    assert result2 == [1, 2, 3, 4]
+    assert call_count == 1
+
+
+def test_memoization_with_unhashable_kwargs() -> None:
+    call_count = 0
+
+    @memoize_last_value
+    def test_func(**kwargs: Any) -> int:
+        nonlocal call_count
+        call_count += 1
+        return sum(kwargs.values())  # type: ignore
+
+    result1 = test_func(a=1, b=2, c=3)
+    assert result1 == 6
+    assert call_count == 1
+
+    result2 = test_func(a=1, b=2, c=3)
+    assert result2 == 6
+    assert call_count == 1  # Should not increase
+
+    result3 = test_func(a=1, b=2, c=3, d=4)
+    assert result3 == 10
+    assert call_count == 2
+
+
+def test_memoization_on_class_methods() -> None:
+    class TestClass:
+        def __init__(self, v: int):
+            self.id = v
+
+        @memoize_last_value
+        def test_method(self, x: int) -> int:
+            return self.id * x
+
+    obj1 = TestClass(2)
+    obj2 = TestClass(3)
+
+    # Test obj1
+    result1 = obj1.test_method(5)
+    assert result1 == 10  # 2 * 5
+
+    # Same input for obj1, should use memoized result
+    result2 = obj1.test_method(5)
+    assert result2 == 10  # 2 * 5
+
+    # Same input for obj2, should compute differently
+    result3 = obj2.test_method(5)
+    assert result3 == 15  # 3 * 5
+
+    # Different input for obj1, should recompute
+    result4 = obj1.test_method(7)
+    assert result4 == 14  # 2 * 7
+
+    # Original input for obj2, should use memoized result
+    result5 = obj2.test_method(5)
+    assert result5 == 15  # 3 * 5
+
+    # Ensure outputs are not shared between instances
+    assert obj1.test_method(10) != obj2.test_method(10)
+
+    # Ensure memoization doesn't work across instances
+    assert obj1.test_method.__func__ is obj2.test_method.__func__
+    assert obj1.test_method.__self__ is not obj2.test_method.__self__


Code review comments:
diff_hunk: @@ -468,6 +469,14 @@ def __init__(
 
     def _stringify(self, value: object) -> str:
         try:
+            # HACK: We pretty-print tables to avoid str(ibis_table)
+            # which can be very slow when `ibis.options.interactive = True`
+            table_manager = get_table_manager_or_none(value)
+            if table_manager:
path: marimo/_messaging/ops.py
text: @user1:
Good to explicitly check for `None` as a rule. I picked up this habit [at Google](https://google.github.io/styleguide/pyguide.html#2144-decision) and it helps avoid bugs with `False`y objects.

```suggestion
            if table_manager is not None:
```