2025, Oct 01 23:00
NiceGUI vs Streamlit: rendering list-valued DataFrame columns with Polars and faster from_polars formatters
Learn how to render list columns in NiceGUI like Streamlit, avoid slow from_polars conversion, and speed up Polars tables with JavaScript column formatters.
Rendering DataFrames with list-valued columns often looks different across UI frameworks. When moving a table from Streamlit to NiceGUI, a common surprise is that a list in a cell no longer appears as a list. Instead, it ends up concatenated into a string, and the default transformation via from_polars() can be noticeably slow. The goal here is to get a display closer to Streamlit while keeping the UI responsive.
What changes when you switch frameworks
In Streamlit, a list inside a DataFrame cell is shown as a list. The same data in NiceGUI, when fed through ui.table.from_polars, is transformed so the list column is concatenated into a string. The transformation step itself can also be slow.
The following minimal examples illustrate the difference with identical data.
Streamlit:
import polars as pl
import streamlit as st
records = pl.DataFrame({
    "u": [1, 2, 3],
    "v": [4, 5, 6],
    "w": [[1, 2], [3, 4], [5, 6]],
})
st.write(records)
NiceGUI (default behavior):
import polars as pl
from nicegui import ui
records = pl.DataFrame({
    "u": [1, 2, 3],
    "v": [4, 5, 6],
    "w": [[1, 2], [3, 4], [5, 6]],
})
ui.table.from_polars(records)
ui.run()
Why it happens
NiceGUI converts the incoming data for the table. In practice, this means list values in cells get turned into strings for display. Looking into the library’s sources (table.py, table.js, dynamic_properties.js) shows JavaScript code using recursion for the conversion, which may contribute to the slowdown. The important part for day-to-day work is that the default conversion does not preserve list rendering the way Streamlit does, and the transformation can be expensive.
How to recover list-like rendering and improve speed
The table API lets you attach a formatter to a column. By assigning a JavaScript formatter to the list column, you take control of how values are displayed and avoid the heavy transformation. A straightforward option is to call String(value), which yields a comma-separated representation of the list in the cell. You can customize this further if needed.
There is one small caveat: you must declare all columns explicitly when using this approach.
from nicegui import ui
import polars as pl
headers = [
    {"name": "u", "label": "u", "field": "u"},
    {"name": "v", "label": "v", "field": "v"},
    {
        "name": "w",
        "label": "w",
        "field": "w",
        ":format": "value => String(value)",  # render list values as a string
    },
]
data = pl.DataFrame({
    "u": [1, 2, 3],
    "v": [4, 5, 6],
    "w": [[1, 2], [3, 4], [5, 6]],
})
ui.table.from_polars(df=data, columns=headers)
ui.run()
This resolves two practical issues at once: the table becomes responsive and the list column is rendered in a readable, comma-separated form. The same idea also works when using pandas with from_pandas().
Why this knowledge is useful
Moving between frameworks is rarely a one-to-one mapping. Understanding how NiceGUI transforms data helps avoid confusing output and unexpected performance hits. Once you know that list-valued cells are converted and that you can override the formatting with a JavaScript formatter, you can achieve output similar to what Streamlit shows and keep your app snappy.
Takeaways
If a list column is concatenated when rendered in NiceGUI and from_polars feels slow, define the columns explicitly and attach a formatter to the list column. String(value) is a simple choice that restores readability and speed, and you can tailor the formatter further to match your desired presentation. If you swap between polars and pandas, apply the same approach with from_pandas. In short, make formatting explicit for list columns and you’ll get predictable, fast results.