Add an Attribute Table to a Layout in PyQGIS

A map with a table beside it answers questions a map alone cannot: which sites these are, how big each one is, when each was last inspected. QGIS's layout attribute table does that, and it is the one layout item with an unusual structure — it is a multiframe, a content object that flows across one or more frames, which is what lets a long table continue onto a second page.

This recipe belongs to Automated Map Layout Generation. It covers creating the table and its frame, choosing and renaming columns, the four content sources, filtering and sorting, and letting a long table paginate.

One table, many framesAn attribute table is a multiframe: a single content object added to the layout, with one or more frames placed on pages. Rows fill the first frame and continue into the next, so a table longer than one page flows onto the second without any manual splitting.The content is one object; the frames are where it landsthe multiframelayer, columns, filteradded to the layoutpage 1 framepage 2 frameframes are created as neededrows flow onward

Prerequisites

Create the table and give it a frame

from qgis.core import (
    QgsProject, QgsLayoutItemAttributeTable, QgsLayoutFrame,
    QgsLayoutPoint, QgsLayoutSize, QgsUnitTypes,
)

project = QgsProject.instance()
layout = project.layoutManager().layoutByName("site_report")
sites = project.mapLayersByName("sites")[0]

table = QgsLayoutItemAttributeTable.create(layout)
table.setVectorLayer(sites)
layout.addMultiFrame(table)

frame = QgsLayoutFrame(layout, table)
frame.attemptMove(QgsLayoutPoint(15, 150, QgsUnitTypes.LayoutMillimeters))
frame.attemptResize(QgsLayoutSize(180, 100, QgsUnitTypes.LayoutMillimeters))
layout.addLayoutItem(frame)
table.addFrame(frame)

Breakdown: Four steps, and skipping any of them produces nothing visible. create() builds the multiframe, addMultiFrame registers it with the layout, the frame is a separate item that must itself be added with addLayoutItem, and addFrame associates the two. A table added without a frame exists and renders nowhere, which is the usual reason a scripted table does not appear. attemptMove and attemptResize are the layout's position API — they are "attempt" because a locked or reference-point-bound item may not end up exactly where asked.

Choose and rename the columns

from qgis.core import QgsLayoutTableColumn

wanted = [
    ("site_id", "Site"),
    ("area_m2", "Area (m²)"),
    ("last_visit", "Last inspected"),
]

columns = []
for field_name, heading in wanted:
    column = QgsLayoutTableColumn()
    column.setAttribute(field_name)
    column.setHeading(heading)
    columns.append(column)

table.setColumns(columns)
table.refreshAttributes()

Breakdown: setColumns replaces the automatic all-fields column set, which is almost always what you want — a raw table of every column including internal keys is rarely publishable. setAttribute takes a field name or an expression, so a computed column like round("area_m2" / 10000, 2) works with no virtual field. setHeading is the printed label and is where the units belong. refreshAttributes() re-reads the source after any change to columns or filters; without it, the table can render its previous contents.

Sorting is a property of the columns rather than of the table:

sort_column = columns[1]
sort_column.setSortOrder(Qt.DescendingOrder)
sort_column.setSortByRank(1)
table.setColumns(columns)

Breakdown: setSortByRank gives the column its place in a multi-column sort, counting from 1; a rank of 0 means the column does not participate. That is how "sort by district, then by area descending" is expressed. Setting the columns again after modifying them is required because setColumns takes a copy.

Where the rows come from

Four sources, four different tablesA layer attributes source lists every feature. Restricting to a map item lists only the features visible in that map. The atlas feature source lists the single feature the atlas is on. The relation children source lists the records related to the atlas feature, which is how a per-site report shows that site's inspections.Pick the source before fiddling with filtersLayerAttributesevery featurevisible in a mapfollows the map extentAtlasFeaturejust this oneRelationChildrenits related recordsthe last two only mean anything with an atlas enabled on the layout

table.setSource(QgsLayoutItemAttributeTable.LayerAttributes)

table.setFilterToAtlasFeature(False)
table.setFilterFeatures(True)
table.setFeatureFilter('"status" = \'open\' AND "area_m2" > 500')

table.setMaximumNumberOfFeatures(40)
table.setDisplayOnlyVisibleFeatures(True)
table.setMap(layout.itemById("main_map"))

table.refreshAttributes()

Breakdown: setFilterFeatures(True) is the switch; setting an expression without it has no effect, which is the most common table-filter confusion. setDisplayOnlyVisibleFeatures restricts rows to those inside the named map item's extent — the combination that makes a table beside a map describe exactly what the map shows, and it needs setMap or it has nothing to test against. setMaximumNumberOfFeatures(0) means unlimited; leaving it at the default of 30 truncates silently, which is worth knowing before someone reports missing rows.

RelationChildren is the source that makes per-feature reports work: with an atlas on parcels, it lists that parcel's inspections without any filter expression, using a relation defined as in defining layer relations.

Making the table readable

The defaults are functional and plain. Four settings do most of the work of making a table look like part of the map rather than a spreadsheet paste.

from qgis.PyQt.QtGui import QFont, QColor

content_font = QFont("Inter", 8)
header_font = QFont("Inter", 8, QFont.Bold)

table.setContentFont(content_font)
table.setHeaderFont(header_font)
table.setShowGrid(True)
table.setGridStrokeWidth(0.15)
table.setGridColor(QColor(120, 120, 120))
table.setBackgroundColor(QColor(255, 255, 255, 0))
table.setCellMargin(1.5)

Breakdown: A grid stroke of 0.15 mm is about as light as prints reliably; the default is heavier and makes a table dominate a page it should support. A fully transparent background — the zero alpha above — lets the table sit over a tinted panel or a map without a white rectangle around it, which is usually what a designed layout wants. Cell margin is in millimetres and is the difference between cramped and legible; 1.5 to 2 mm suits an 8 pt font.

Column widths are set per column rather than on the table, through setWidth on each QgsLayoutTableColumn, in millimetres, with 0 meaning automatic. Mixing fixed and automatic widths is legitimate and is how you stop a long text column squeezing the numeric ones — pin the numbers, let the text take what is left.

Letting a long table paginate

from qgis.core import QgsLayoutMultiFrame

table.setResizeMode(QgsLayoutMultiFrame.RepeatUntilFinished)
table.setEmptyTableBehavior(QgsLayoutTable.ShowMessage)
table.setEmptyTableMessage("No open sites in this area")
table.recalculateFrameSizes()

Breakdown: RepeatUntilFinished creates new frames — and new pages — until every row is placed, which is what turns a fixed box into a report. ExtendToNextPage is the variant that only continues onto existing pages. RepeatOnEveryPage places a copy of the same first rows on each page and is almost never what you want. The empty-table behaviour matters more than it sounds: the default draws headers over an empty box, which reads as a broken layout, while a message reads as a result.

Keeping the table in step with the map

A table beside a map is only useful if the two agree, and there are two ways they drift apart.

The first is the extent. setDisplayOnlyVisibleFeatures reads the map item's extent at render time, so it stays correct when the map moves — including when an atlas drives it. What it does not account for is a feature that is within the extent but hidden by scale-based visibility or by a subset string, so the map shows fewer features than the table lists. Where that matters, repeat the visibility condition as a feature filter on the table.

The second is the layer. A table holds a reference to the layer it was given, so removing and re-adding that layer — as a refresh script might — leaves the table pointing at nothing and rendering empty. Re-setting the vector layer after any such operation is the fix, and it is cheap:

sites = project.mapLayersByName("sites")[0]
for item in layout.multiFrames():
    if isinstance(item, QgsLayoutItemAttributeTable):
        item.setVectorLayer(sites)
        item.refreshAttributes()

Breakdown: layout.multiFrames() returns the content objects rather than the frames, which is the right level to work at here — one call fixes a table however many frames it spans. Testing the type rather than assuming keeps the loop safe on a layout that also contains an HTML multiframe. Running this at the end of any script that reloads layers removes an entire class of "the table was empty in last night's export" report.

Adding a totals row

There is no built-in summary row, and the usual approach is a separate label positioned under the frame, carrying an expression that aggregates the same features:

from qgis.core import QgsLayoutItemLabel

total = QgsLayoutItemLabel(layout)
total.setText("[% 'Total area: ' || format_number(sum(\"area_m2\"), 0) || ' m²' %]")
total.attemptMove(QgsLayoutPoint(15, 255, QgsUnitTypes.LayoutMillimeters))
layout.addLayoutItem(total)

Breakdown: The [% ... %] delimiters are what make a layout label evaluate an expression rather than print it literally, and forgetting them is why a label sometimes shows its own source. sum() here aggregates over the label's layer context rather than over the table's filter, so a filtered table and an unfiltered total will disagree — repeating the filter inside the aggregate's filter argument keeps them consistent. The wider expression mechanics are covered in atlas expressions and dynamic text.

QGIS version compatibility

QgsLayoutItemAttributeTable and the multiframe model arrived with the layout rewrite in QGIS 3.0 and are unchanged in shape since. setEmptyTableBehavior and the sort-by-rank column API have been present throughout 3.x. The Qt.DescendingOrder constant comes from qgis.PyQt.QtCore, which is the import you need alongside the layout classes.

Troubleshooting

  • The table does not appear. A missing addMultiFrame, addLayoutItem or addFrame — all four steps are required.
  • Only thirty rows show. The default maximum. Set it to 0 for unlimited.
  • The filter has no effect. setFilterFeatures(True) was not called.
  • "Only visible features" shows everything. No map item was set with setMap.
  • The table is stale after changing the layer. Call refreshAttributes().
  • A long table is clipped rather than continuing. Resize mode is the default rather than RepeatUntilFinished.

Conclusion

Build the multiframe, add it, add a frame, associate them — then set columns explicitly, turn the filter on before setting it, point the table at a map if it should follow the extent, and choose a resize mode that lets it flow. A table is the item that turns a map into a report, and every part of it is scriptable.

Frequently Asked Questions

Can the table show a computed column? Yes — setAttribute accepts an expression, so unit conversions and concatenations need no field in the data.

How do I style the table?setContentFont, setHeaderFont, setGridStrokeWidth, setShowGrid and the background colour setters are all on the table object, and all take effect on the next refresh.

Can I put two tables on one layout? Yes, each as its own multiframe with its own frames. They are independent, so one can list sites and another their inspections.

Does the table appear in an exported PDF? Yes, as vector text, so it remains selectable and searchable — see exporting multiple layouts to PDF.