Displaying NumPy Arrays and Pandas Data in QTableView Cells

How to handle complex data like NumPy arrays and Pandas Series inside a QAbstractTableModel
Heads up! You've already completed this tutorial.

When working with scientific data in Python, you'll often encounter situations where each "cell" in your dataset contains more than a single value. Maybe each entry holds a NumPy array of measurements, a Pandas Series of time-series data, or even a small table. How do you display that kind of nested, hierarchical data inside a QTableView?

Qt's model/view architecture is flexible enough to handle this, but it requires a bit of thought about what you show in each cell and how users interact with the underlying data. In this tutorial, we'll walk through practical approaches for displaying complex data in a QTableView — from showing summaries in cells to popping out full table editors on demand.

If you're new to Qt's model/view framework, you may want to read the introduction to the ModelView architecture first.

The challenge: arrays inside cells

Imagine you have a dataset where each row represents a measurement item, and one of the columns contains a NumPy array or Pandas Series — perhaps 800 to 2000 values per cell. You can't just dump all those numbers into a single table cell. Instead, you need a strategy:

  1. Summary view — Show a compact representation in the cell (e.g., dimensions, min/max, mean).
  2. Detail view — Let the user drill into the full array, perhaps by double-clicking to open a pop-out table.

We'll build both of these, step by step.

Setting up the data

First, let's create some sample data. We'll use a list of dictionaries where some values are plain numbers or strings, and others are NumPy arrays.

python
import numpy as np

data = [
    {"Name": "Sensor A", "Location": "Lab 1", "Readings": np.random.rand(100)},
    {"Name": "Sensor B", "Location": "Lab 2", "Readings": np.random.rand(150)},
    {"Name": "Sensor C", "Location": "Lab 3", "Readings": np.random.rand(200)},
    {"Name": "Sensor D", "Location": "Lab 1", "Readings": np.random.rand(80)},
]

Each "Readings" entry is a NumPy array with a different number of values. Our goal is to display this in a QTableView where the "Readings" column shows something useful at a glance.

Displaying summary information in cells

The simplest approach is to show summary data — like the array's shape, min, max, or mean — directly in the cell. You do this by customizing the data() method of your QAbstractTableModel.

Here's a complete working example:

python
import sys
import numpy as np
from PyQt5.QtCore import Qt, QAbstractTableModel
from PyQt5.QtWidgets import QApplication, QTableView, QMainWindow


sample_data = [
    {"Name": "Sensor A", "Location": "Lab 1", "Readings": np.random.rand(100)},
    {"Name": "Sensor B", "Location": "Lab 2", "Readings": np.random.rand(150)},
    {"Name": "Sensor C", "Location": "Lab 3", "Readings": np.random.rand(200)},
    {"Name": "Sensor D", "Location": "Lab 1", "Readings": np.random.rand(80)},
]


class ArraySummaryModel(QAbstractTableModel):
    def __init__(self, data):
        super().__init__()
        self._data = data
        self._columns = list(data[0].keys())

    def rowCount(self, parent=None):
        return len(self._data)

    def columnCount(self, parent=None):
        return len(self._columns)

    def data(self, index, role=Qt.DisplayRole):
        if not index.isValid():
            return None

        if role == Qt.DisplayRole:
            column_name = self._columns[index.column()]
            value = self._data[index.row()][column_name]

            # If the value is a NumPy array, show a summary instead
            if isinstance(value, np.ndarray):
                return (
                    f"[{len(value)} values] "
                    f"min={value.min():.3f}, "
                    f"max={value.max():.3f}, "
                    f"mean={value.mean():.3f}"
                )

            return str(value)

        return None

    def headerData(self, section, orientation, role=Qt.DisplayRole):
        if role == Qt.DisplayRole and orientation == Qt.Horizontal:
            return self._columns[section]
        return None


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("NumPy Array Summary in QTableView")
        self.resize(700, 300)

        self.table = QTableView()
        self.model = ArraySummaryModel(sample_data)
        self.table.setModel(self.model)

        # Stretch the last column to fill available space
        header = self.table.horizontalHeader()
        header.setStretchLastSection(True)

        self.setCentralWidget(self.table)


app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec_())

Run this and you'll see a table where the "Readings" column displays a compact summary of each array. The actual data is still there in memory — we're just choosing what to show in the data() method.

PyQt6 Crash Course by Martin Fitzpatrick — The important parts of PyQt6 in bite-size chunks

See the course

Summary view of NumPy arrays in a QTableView

Pre-computing summaries for performance

The approach above recalculates the summary every time Qt asks for the cell's display data. For small arrays that's fine, but if your arrays contain thousands of values and you have hundreds of rows, this will get slow. Qt calls data() frequently — during scrolling, resizing, and repainting.

A better approach is to compute the summaries once when the data is loaded, and store them alongside the raw data:

python
class ArraySummaryModel(QAbstractTableModel):
    def __init__(self, data):
        super().__init__()
        self._data = data
        self._columns = list(data[0].keys())
        self._summaries = self._compute_summaries()

    def _compute_summaries(self):
        """Pre-compute display strings for array-type cells."""
        summaries = {}
        for row_idx, row in enumerate(self._data):
            for col_name, value in row.items():
                if isinstance(value, np.ndarray):
                    summaries[(row_idx, col_name)] = (
                        f"[{len(value)} values] "
                        f"min={value.min():.3f}, "
                        f"max={value.max():.3f}, "
                        f"mean={value.mean():.3f}"
                    )
        return summaries

    def data(self, index, role=Qt.DisplayRole):
        if not index.isValid():
            return None

        if role == Qt.DisplayRole:
            column_name = self._columns[index.column()]
            row_idx = index.row()

            # Check if we have a pre-computed summary
            key = (row_idx, column_name)
            if key in self._summaries:
                return self._summaries[key]

            return str(self._data[row_idx][column_name])

        return None

    def rowCount(self, parent=None):
        return len(self._data)

    def columnCount(self, parent=None):
        return len(self._columns)

    def headerData(self, section, orientation, role=Qt.DisplayRole):
        if role == Qt.DisplayRole and orientation == Qt.Horizontal:
            return self._columns[section]
        return None

Now the summaries are computed once in _compute_summaries(). If the underlying data changes, you'd call that method again and emit layoutChanged to refresh the view.

Adding a pop-out detail view

Summaries are great for quick scanning, but sometimes users need to see the full array. A natural interaction is to double-click a cell and open a second QTableView showing all the values.

We'll create a small dialog that displays the array contents:

python
import sys
import numpy as np
from PyQt5.QtCore import Qt, QAbstractTableModel
from PyQt5.QtWidgets import (
    QApplication, QTableView, QMainWindow, QDialog,
    QVBoxLayout, QLabel
)


sample_data = [
    {"Name": "Sensor A", "Location": "Lab 1", "Readings": np.random.rand(100)},
    {"Name": "Sensor B", "Location": "Lab 2", "Readings": np.random.rand(150)},
    {"Name": "Sensor C", "Location": "Lab 3", "Readings": np.random.rand(200)},
    {"Name": "Sensor D", "Location": "Lab 1", "Readings": np.random.rand(80)},
]


class ArrayDetailModel(QAbstractTableModel):
    """Model for displaying a single NumPy array in a table."""

    def __init__(self, array):
        super().__init__()
        self._array = array

    def rowCount(self, parent=None):
        return len(self._array)

    def columnCount(self, parent=None):
        return 1

    def data(self, index, role=Qt.DisplayRole):
        if role == Qt.DisplayRole:
            return f"{self._array[index.row()]:.6f}"
        return None

    def headerData(self, section, orientation, role=Qt.DisplayRole):
        if role == Qt.DisplayRole:
            if orientation == Qt.Vertical:
                return str(section)
            return "Value"
        return None


class ArrayDetailDialog(QDialog):
    """Pop-out dialog to display full array contents."""

    def __init__(self, array, title="Array Detail", parent=None):
        super().__init__(parent)
        self.setWindowTitle(title)
        self.resize(300, 500)

        layout = QVBoxLayout()

        info_label = QLabel(
            f"Shape: {array.shape}  |  "
            f"Min: {array.min():.4f}  |  "
            f"Max: {array.max():.4f}  |  "
            f"Mean: {array.mean():.4f}"
        )
        layout.addWidget(info_label)

        table = QTableView()
        model = ArrayDetailModel(array)
        table.setModel(model)
        table.horizontalHeader().setStretchLastSection(True)
        layout.addWidget(table)

        self.setLayout(layout)


class ArraySummaryModel(QAbstractTableModel):
    def __init__(self, data):
        super().__init__()
        self._data = data
        self._columns = list(data[0].keys())

    def rowCount(self, parent=None):
        return len(self._data)

    def columnCount(self, parent=None):
        return len(self._columns)

    def data(self, index, role=Qt.DisplayRole):
        if not index.isValid():
            return None

        if role == Qt.DisplayRole:
            column_name = self._columns[index.column()]
            value = self._data[index.row()][column_name]

            if isinstance(value, np.ndarray):
                return (
                    f"[{len(value)} values] "
                    f"min={value.min():.3f}, "
                    f"max={value.max():.3f}, "
                    f"mean={value.mean():.3f}"
                )

            return str(value)

        return None

    def headerData(self, section, orientation, role=Qt.DisplayRole):
        if role == Qt.DisplayRole and orientation == Qt.Horizontal:
            return self._columns[section]
        return None

    def get_raw_value(self, row, col):
        """Return the raw Python object for a given cell."""
        column_name = self._columns[col]
        return self._data[row][column_name]


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("NumPy Arrays in QTableView")
        self.resize(700, 300)

        self.table = QTableView()
        self.model = ArraySummaryModel(sample_data)
        self.table.setModel(self.model)

        header = self.table.horizontalHeader()
        header.setStretchLastSection(True)

        # Connect double-click to open detail view
        self.table.doubleClicked.connect(self.on_cell_double_clicked)

        self.setCentralWidget(self.table)

    def on_cell_double_clicked(self, index):
        value = self.model.get_raw_value(index.row(), index.column())

        if isinstance(value, np.ndarray):
            row_data = self.model._data[index.row()]
            title = f"Detail: {row_data.get('Name', 'Array')}"
            dialog = ArrayDetailDialog(value, title=title, parent=self)
            dialog.exec_()


app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec_())

Double-click any cell in the "Readings" column and a dialog opens showing the full array in its own QTableView, along with summary statistics at the top. Clicking on non-array cells (like "Name" or "Location") does nothing special — the isinstance check ensures we only open the dialog for NumPy arrays.

Working with Pandas Series and DataFrames

If your data uses Pandas instead of raw NumPy arrays, the same pattern applies. You just need to adjust the detail model to handle Series or DataFrame objects. For a more complete guide on using Pandas with QTableView, see the QTableView with NumPy and Pandas tutorial. Here's a version of ArrayDetailModel that works with Pandas Series:

python
import pandas as pd


class SeriesDetailModel(QAbstractTableModel):
    """Model for displaying a Pandas Series in a table."""

    def __init__(self, series):
        super().__init__()
        self._series = series

    def rowCount(self, parent=None):
        return len(self._series)

    def columnCount(self, parent=None):
        return 1

    def data(self, index, role=Qt.DisplayRole):
        if role == Qt.DisplayRole:
            return str(self._series.iloc[index.row()])
        return None

    def headerData(self, section, orientation, role=Qt.DisplayRole):
        if role == Qt.DisplayRole:
            if orientation == Qt.Vertical:
                return str(self._series.index[section])
            return self._series.name or "Value"
        return None

For a full DataFrame stored in a cell, you'd extend this to handle multiple columns:

python
class DataFrameDetailModel(QAbstractTableModel):
    """Model for displaying a Pandas DataFrame in a table."""

    def __init__(self, df):
        super().__init__()
        self._df = df

    def rowCount(self, parent=None):
        return len(self._df)

    def columnCount(self, parent=None):
        return len(self._df.columns)

    def data(self, index, role=Qt.DisplayRole):
        if role == Qt.DisplayRole:
            value = self._df.iloc[index.row(), index.column()]
            return str(value)
        return None

    def headerData(self, section, orientation, role=Qt.DisplayRole):
        if role == Qt.DisplayRole:
            if orientation == Qt.Horizontal:
                return str(self._df.columns[section])
            return str(self._df.index[section])
        return None

You can then choose which detail model to use based on the type of data in the cell:

python
def on_cell_double_clicked(self, index):
    value = self.model.get_raw_value(index.row(), index.column())

    if isinstance(value, np.ndarray):
        dialog = ArrayDetailDialog(value, parent=self)
        dialog.exec_()
    elif isinstance(value, pd.Series):
        # Use SeriesDetailModel in a similar dialog
        ...
    elif isinstance(value, pd.DataFrame):
        # Use DataFrameDetailModel in a similar dialog
        ...

Handling hierarchical data (like PyTables)

If you're working with PyTables (HDF5) or other hierarchical data formats, the same principles apply, but you'll also want a tree structure to navigate the hierarchy. Qt provides QTreeView and QAbstractItemModel for exactly this purpose.

A common pattern for hierarchical scientific data is:

  • Use a QTreeView on the left side to navigate groups and datasets in the HDF5 file.
  • When the user selects a dataset, display it in a QTableView on the right side.
  • If a cell within that table contains a nested array, use the double-click pop-out pattern described above.

This is essentially a master-detail layout, and Qt's signals and slots system makes it straightforward to wire together.

Complete working example

Here's the full application with summary display and pop-out detail views, supporting both NumPy arrays and Pandas Series:

python
import sys
import numpy as np
import pandas as pd
from PyQt5.QtCore import Qt, QAbstractTableModel
from PyQt5.QtWidgets import (
    QApplication, QTableView, QMainWindow, QDialog,
    QVBoxLayout, QLabel
)


# Sample data mixing plain values with arrays and Series
sample_data = [
    {
        "Name": "Sensor A",
        "Location": "Lab 1",
        "Readings": np.random.rand(100),
        "Calibration": pd.Series(
            np.random.rand(10) * 2,
            index=[f"cal_{i}" for i in range(10)],
            name="Calibration",
        ),
    },
    {
        "Name": "Sensor B",
        "Location": "Lab 2",
        "Readings": np.random.rand(150),
        "Calibration": pd.Series(
            np.random.rand(12) * 2,
            index=[f"cal_{i}" for i in range(12)],
            name="Calibration",
        ),
    },
    {
        "Name": "Sensor C",
        "Location": "Lab 3",
        "Readings": np.random.rand(200),
        "Calibration": pd.Series(
            np.random.rand(8) * 2,
            index=[f"cal_{i}" for i in range(8)],
            name="Calibration",
        ),
    },
    {
        "Name": "Sensor D",
        "Location": "Lab 1",
        "Readings": np.random.rand(80),
        "Calibration": pd.Series(
            np.random.rand(15) * 2,
            index=[f"cal_{i}" for i in range(15)],
            name="Calibration",
        ),
    },
]


class ArrayDetailModel(QAbstractTableModel):
    """Model for displaying a NumPy array in a table."""

    def __init__(self, array):
        super().__init__()
        self._array = array

    def rowCount(self, parent=None):
        return len(self._array)

    def columnCount(self, parent=None):
        return 1

    def data(self, index, role=Qt.DisplayRole):
        if role == Qt.DisplayRole:
            return f"{self._array[index.row()]:.6f}"
        return None

    def headerData(self, section, orientation, role=Qt.DisplayRole):
        if role == Qt.DisplayRole:
            if orientation == Qt.Vertical:
                return str(section)
            return "Value"
        return None


class SeriesDetailModel(QAbstractTableModel):
    """Model for displaying a Pandas Series in a table."""

    def __init__(self, series):
        super().__init__()
        self._series = series

    def rowCount(self, parent=None):
        return len(self._series)

    def columnCount(self, parent=None):
        return 1

    def data(self, index, role=Qt.DisplayRole):
        if role == Qt.DisplayRole:
            return f"{self._series.iloc[index.row()]:.6f}"
        return None

    def headerData(self, section, orientation, role=Qt.DisplayRole):
        if role == Qt.DisplayRole:
            if orientation == Qt.Vertical:
                return str(self._series.index[section])
            return self._series.name or "Value"
        return None


class DetailDialog(QDialog):
    """Pop-out dialog to display full array or Series contents."""

    def __init__(self, value, title="Detail View", parent=None):
        super().__init__(parent)
        self.setWindowTitle(title)
        self.resize(300, 500)

        layout = QVBoxLayout()

        # Build summary info depending on the data type
        if isinstance(value, np.ndarray):
            info_text = (
                f"NumPy array  |  Shape: {value.shape}  |  "
                f"Min: {value.min():.4f}  |  "
                f"Max: {value.max():.4f}  |  "
                f"Mean: {value.mean():.4f}"
            )
            model = ArrayDetailModel(value)

        elif isinstance(value, pd.Series):
            info_text = (
                f"Pandas Series  |  Length: {len(value)}  |  "
                f"Min: {value.min():.4f}  |  "
                f"Max: {value.max():.4f}  |  "
                f"Mean: {value.mean():.4f}"
            )
            model = SeriesDetailModel(value)

        else:
            info_text = f"Type: {type(value).__name__}"
            model = None

        info_label = QLabel(info_text)
        info_label.setWordWrap(True)
        layout.addWidget(info_label)

        if model is not None:
            table = QTableView()
            table.setModel(model)
            table.horizontalHeader().setStretchLastSection(True)
            layout.addWidget(table)

        self.setLayout(layout)


class SummaryTableModel(QAbstractTableModel):
    """
    Table model that displays plain values normally and shows
    summaries for NumPy arrays and Pandas Series.
    """

    def __init__(self, data):
        super().__init__()
        self._data = data
        self._columns = list(data[0].keys())
        self._summaries = self._compute_summaries()

    def _compute_summaries(self):
        """Pre-compute display strings for complex cells."""
        summaries = {}
        for row_idx, row in enumerate(self._data):
            for col_name, value in row.items():
                if isinstance(value, np.ndarray):
                    summaries[(row_idx, col_name)] = (
                        f"[{len(value)} values] "
                        f"min={value.min():.3f}, "
                        f"max={value.max():.3f}, "
                        f"mean={value.mean():.3f}"
                    )
                elif isinstance(value, pd.Series):
                    summaries[(row_idx, col_name)] = (
                        f"[Series: {len(value)}] "
                        f"min={value.min():.3f}, "
                        f"max={value.max():.3f}, "
                        f"mean={value.mean():.3f}"
                    )
        return summaries

    def rowCount(self, parent=None):
        return len(self._data)

    def columnCount(self, parent=None):
        return len(self._columns)

    def data(self, index, role=Qt.DisplayRole):
        if not index.isValid():
            return None

        if role == Qt.DisplayRole:
            column_name = self._columns[index.column()]
            key = (index.row(), column_name)

            if key in self._summaries:
                return self._summaries[key]

            return str(self._data[index.row()][column_name])

        # Show a tooltip hint for array cells
        if role == Qt.ToolTipRole:
            column_name = self._columns[index.column()]
            value = self._data[index.row()][column_name]
            if isinstance(value, (np.ndarray, pd.Series)):
                return "Double-click to view full data"

        return None

    def headerData(self, section, orientation, role=Qt.DisplayRole):
        if role == Qt.DisplayRole and orientation == Qt.Horizontal:
            return self._columns[section]
        return None

    def get_raw_value(self, row, col):
        """Return the raw Python object for a given cell."""
        column_name = self._columns[col]
        return self._data[row][column_name]


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("NumPy & Pandas Data in QTableView")
        self.resize(900, 300)

        self.table = QTableView()
        self.model = SummaryTableModel(sample_data)
        self.table.setModel(self.model)

        header = self.table.horizontalHeader()
        header.setStretchLastSection(True)

        # Double-click opens detail view for complex cells
        self.table.doubleClicked.connect(self.on_cell_double_clicked)

        self.setCentralWidget(self.table)

    def on_cell_double_clicked(self, index):
        value = self.model.get_raw_value(index.row(), index.column())

        if isinstance(value, (np.ndarray, pd.Series)):
            row_data = self.model._data[index.row()]
            col_name = self.model._columns[index.column()]
            title = f"{row_data.get('Name', 'Data')} — {col_name}"
            dialog = DetailDialog(value, title=title, parent=self)
            dialog.exec_()


app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec_())

This gives you a main table showing compact summaries in each cell that contains an array or Series. Double-clicking any of those cells opens a dialog with the full data in its own scrollable table view.

The pattern of "summary in the table, detail on demand" scales well even with hundreds of items containing large arrays, especially when you pre-compute the summaries as shown above.

Over 15,000 developers have bought Create GUI Applications with Python & Qt!
Create GUI Applications with Python & Qt6
Get the book

Downloadable ebook (PDF, ePub) & Complete Source code

[[ discount.discount_pc ]]% OFF for the next [[ discount.duration ]] [[discount.description ]] with the code [[ discount.coupon_code ]]

Purchasing Power Parity

Developers in [[ country ]] get [[ discount.discount_pc ]]% OFF on all books & courses with code [[ discount.coupon_code ]]
Well done, you've finished this tutorial! Mark As Complete
[[ user.completed.length ]] completed [[ user.streak+1 ]] day streak
Martin Fitzpatrick

Displaying NumPy Arrays and Pandas Data in QTableView Cells was written by Martin Fitzpatrick.

Martin Fitzpatrick is the creator of Python GUIs, and has been developing Python/Qt applications for the past 12+ years. He has written a number of popular Python books and provides Python software development & consulting for teams and startups.