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:
- Summary view — Show a compact representation in the cell (e.g., dimensions, min/max, mean).
- 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.
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:
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

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:
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:
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:
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:
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:
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
QTreeViewon the left side to navigate groups and datasets in the HDF5 file. - When the user selects a dataset, display it in a
QTableViewon 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:
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.