Question: I wrote an extension for Sphinx to read code coverage files and present them as a table in a Sphinx generated HTML documentation.  Currently the table has a single header row with e.g. 3 columns for statement related values and 4 columns for branch related data. I would like to create a 2 row table header, so multiple columns are grouped. In pure HTML it would be done by adding colspan=3. But how to solve that question with docutils? The full sources can be found here: https://github.com/pyTooling/sphinx-reports/blob/main/sphinx_reports/CodeCoverage.py#L169 Interesting code is this:  def _PrepareTable(self, columns: Dict[str, int], identifier: str, classes: List[str]) -> Tuple[nodes.table, nodes.tgroup]: table = nodes.table("", identifier=identifier, classes=classes) tableGroup = nodes.tgroup(cols=(len(columns))) table += tableGroup tableRow = nodes.row() for columnTitle, width in columns.items(): tableGroup += nodes.colspec(colwidth=width) tableRow += nodes.entry("", nodes.paragraph(text=columnTitle)) tableGroup += nodes.thead("", tableRow) return table, tableGroup def _GenerateCoverageTable(self) -> nodes.table: # Create a table and table header with 5 columns table, tableGroup = self._PrepareTable( identifier=self._packageID, columns={ "Module": 500, "Total Statements": 100, "Excluded Statements": 100, "Covered Statements": 100, "Missing Statements": 100, "Total Branches": 100, "Covered Branches": 100, "Partial Branches": 100, "Missing Branches": 100, "Coverage in %": 100 }, classes=["report-doccov-table"] ) tableBody = nodes.tbody() tableGroup += tableBody 

Answer: The magic of multiple cells spanning rows or columns is done by morerows and morecols. In addition, merged cells need to be set as None. I found it by investigating the code for the table parser. Like always with Sphinx and docutils, such features are not documented (but isn't docutils and Sphinx meant to document code/itself?). Anyhow, I created a helper method which returns a table node with header rows in it. I used a simple approach to describe header columns in the primary rows that are divided into more columns in a secondary row. Alternatively, @Bhav-Bhela demonstrated a description technique for deeper nesting. The method expects a list of primary column descriptions, which is a tuple of column title, optional list of secondary columns, column width. If the secondary column list is present, then no column width is needed for the primary row. In the secondary row, a tuple of title and column width is used. from typing import Optional as Nullable List[ Tuple[str, Nullable[List[ Tuple[str, int]] ], Nullable[int]] ]  class BaseDirective(ObjectDescription): # ... def _PrepareTable(self, columns: List[Tuple[str, Nullable[List[Tuple[str, int]]], Nullable[int]]], identifier: str, classes: List[str]) -> Tuple[nodes.table, nodes.tgroup]: table = nodes.table("", identifier=identifier, classes=classes) hasSecondHeaderRow = False columnCount = 0 for groupColumn in columns: if groupColumn[1] is not None: columnCount += len(groupColumn[1]) hasSecondHeaderRow = True else: columnCount += 1 tableGroup = nodes.tgroup(cols=columnCount) table += tableGroup # Setup column specifications for _, more, width in columns: if more is None: tableGroup += nodes.colspec(colwidth=width) else: for _, width in more: tableGroup += nodes.colspec(colwidth=width) # Setup primary header row headerRow = nodes.row() for columnTitle, more, _ in columns: if more is None: headerRow += nodes.entry("", nodes.paragraph(text=columnTitle), morerows=1) else: morecols = len(more) - 1 headerRow += nodes.entry("", nodes.paragraph(text=columnTitle), morecols=morecols) for i in range(morecols): headerRow += None tableHeader = nodes.thead("", headerRow) tableGroup += tableHeader # If present, setup secondary header row if hasSecondHeaderRow: tableRow = nodes.row() for columnTitle, more, _ in columns: if more is None: tableRow += None else: for columnTitle, _ in more: tableRow += nodes.entry("", nodes.paragraph(text=columnTitle)) tableHeader += tableRow return table, tableGroup  It's then used like that: class CodeCoverage(BaseDirective): # ... def _GenerateCoverageTable(self) -> nodes.table: # Create a table and table header with 10 columns table, tableGroup = self._PrepareTable( identifier=self._packageID, columns=[ ("Package", [ (" Module", 500) ], None), ("Statments", [ ("Total", 100), ("Excluded", 100), ("Covered", 100), ("Missing", 100) ], None), ("Branches", [ ("Total", 100), ("Covered", 100), ("Partial", 100), ("Missing", 100) ], None), ("Coverage", [ ("in %", 100) ], None) ], classes=["report-codecov-table"] ) tableBody = nodes.tbody() tableGroup += tableBody def run(self) -> List[nodes.Node]: self._CheckOptions() container = nodes.container() container += self._GenerateCoverageTable() return [container]  The full code can be found here: Sphinx.py:BaseDirective._PrepareTable The result looks like this:  Link to example: https://pytooling.github.io/sphinx-reports/coverage/index.html