TabulariaDocs

DocumentationCapabilities

Pixel-perfect reports

A report is a page of fixed size, designed in the browser to the tenth of a millimetre: a header and a footer on every page, tables that group and subtotal over as many pages as the data needs, charts, gauges and KPI cards, texts written with expressions. It is an object of a folder, with its versions, like a flow or a SQL script. Readers open it on screen with its parameters and download it as PDF or Excel; a flow group sends it by email right after the data it shows is refreshed; a script downloads it through the API.

Use the Viewer to explore a datasource: filters, pivots and charts that change as you ask. Use a report when the answer has to arrive as a document: the same layout every month, page numbers and totals in their place, for people who never open Tabularia and read it as a PDF or in their inbox. Use a dashboard when the screen is the answer: tiles on a grid that adapt to the window and filter each other at a click. Every installation has reports, Docker Compose included, and the sample data comes with two of them to open and study.

The report designer: the page in millimetres with its header, three KPI cards, a combo chart, a table grouped by category and a matrix; the elements on the left, the selected table's properties on the right

The designer with the report of the example below: the table is selected, its rows labelled on the right of the page (header, group, rows, the group's total, the grand total), its properties in the panel.

At a glance

WhereExplore → New → Report, in a folder; it opens in the designer. Readers open it in the viewer from Explore or from Analysis → Reports, the most read first
What it readsup to 20 datasets: SQL on the catalog or on a database connection, a datasource, a saved view, a saved SQL script
What it showstexts and expressions, images, lines, rectangles, tables and matrices with groups and totals, 14 kinds of chart, KPI cards, and an AI comment on the numbers
Parameterstext, number, date or yes/no, one value or several, fixed choices or choices from a dataset; in the SQL as {name}
FormatsPDF (the page as designed), Excel (the tables with their groups and subtotals), the summary in an email
Sendingfrom a flow group's Pushmail cell, after the steps that refresh the data; a report has no schedule of its own
From codeGET /reports/{id}/render with an API token (API), tabularia.report() in a marimo notebook
RightsVIEW on the folder to open and download it, EDIT to design it; the data is read with the rights of whoever reads the report
Limits3,000,000 rows per dataset and 200 pages per render by default (REPORTS__*)

The designer

New → Report in a folder creates «New report» and opens it in the designer. The bar at the top holds the report's name (write over it to rename), its folder, whether it is saved and its version, undo and redo, the zoom, Snap to the millimetre grid, Page setup, Design and Preview, Save and Done (save and open the report as readers see it). The page sits in the middle with its rulers in millimetres; on the left, Insert holds the elements and Data the datasets and the parameters; on the right, the properties of what is selected: an item, a band (a click on its name beside the page) or, with nothing selected, the report.

The page and its bands

Page setup sets the paper (A4, A3, A5, Letter, Legal or a size of your own in millimetres), the orientation and the four margins; the language of the report (English, Italian, German, Spanish, French: the separators of the numbers, the names of months and days, and the words the report writes by itself, «Page 1 of 3», «Total»); its time zone ({today} and NOW() are read in it); and the theme: the font (the PDF embeds IBM Plex Sans), its size, the text colour and the accent that colours the charts and the sparklines.

A page has three bands:

BandWhat it holds
Page header, Page footera fixed height, printed on every page; Also on the first page and Also on the last page turned off leave them out there. Texts, images, lines and rectangles only: what is the same on every page (a title, a logo, the parameters chosen, "Page " & PAGE() & " of " & PAGES()). A field of a dataset is refused here; an aggregate of a named dataset, SUM([amount], "orders"), is not
Bodyflows over the pages. Items whose heights overlap make a row and are placed side by side; a table takes its row alone, grows with its data and pushes down what is under it (an item dropped beside a table goes under it)

Every position and size is in millimetres, to a tenth. Items snap to the millimetre grid while you drag them; the edges and centres of the other items always guide. Held near an edge of the page's area, a dragged item scrolls it. Where items overlap, the one drawn last is on top: in the header and the footer the one added last, in the body the one lower on the page (then the one more to the right), in the designer as in the PDF: a rectangle meant as a background starts at or above the texts it frames.

Elements

Drag an element from Insert onto the page, or click it to add it under what the page holds already:

ElementWhat it is
Texttext as written, or an expression when it starts with =: ="Sales in " & PARAM("region"). Size, weight, italic, colours, alignment, padding, border, a format, and Grows with a long text
Imagea PNG, JPEG or SVG of at most 512 KiB, kept inside the report (never a link): Whole, Fill or Stretch. An SVG that links outside itself or runs scripts is refused when the report is saved
Line, Rectanglea rule, a frame or a coloured band
Tablethe rows of a dataset, with columns, groups, subtotals and totals: tables and matrices
Matrixa table with one column per value of a field
Chart14 kinds, chosen when it is placed: charts and KPIs
KPIone value in large type, its change against another and a sparkline
AI commenta short text the configured AI model writes on the numbers of the elements you pick: below

Ready-made texts are texts written for you: Page number of total, Date and time of printing, The report's name, and Value of … for each parameter.

In Data, a field dragged onto a table becomes a new column, and anywhere else in the body a text that shows it; a click on a field adds it as a column to the selected table. A table reads one dataset: a field of another is refused.

The keys: Ctrl+S saves, Ctrl+Z and Ctrl+Shift+Z undo and redo; on the page, the arrows move the selected item by 1 mm (5 mm with Shift), Ctrl+D duplicates it, Ctrl+C and Ctrl+V copy and paste, Del removes it. A copy can be pasted into another report, open in another tab too; if it read a dataset that report does not have, it reads its only dataset, or waits for one to be chosen, and says so. Tab goes through the bands' names and the items; Enter on a band's name selects it.

Saving and the preview

Save keeps the report as a new version. The server checks the whole definition at every save: a field that does not exist, a function with the wrong arguments, a text added to a number. A save it refuses lists the problems in the bar; the first item is selected and the message sits under its field, with the character where the mistake is. The expression fields check what they can while you type: an expression you are still writing (SUM([ric) is not a mistake, and is said only if you leave the field that way; one that is finished and wrong is said at once. After a pause the server checks the report as a save would (the types, where a field may be used, the formats) and says what it finds under the same fields. If someone else saved the report since you opened it, Save says so and asks before your version becomes the newest: theirs stays among the versions.

Preview renders the report as it is on screen, saved or not, with the values of its parameters you choose in the same form the viewer has (the lists read from a dataset included): the same renderer as the PDF, so what you see is what will print. A report with mistakes is not rendered: the preview lists them, and a click on one takes you to its field in Design.

In the designer a table is dragged under the chart; the fields Month, ricavo, costo and margine are dragged into it one after the other; a group by categoria adds the group's row, with no total yet; Sum is chosen in the Total list of ricavo, costo and margine, and the group's total row appears; Preview renders the page with the parameters above it; Salami chosen in Category and View report render that category, down to its total

A table under the chart: four fields dragged into it, a group by [categoria] written in its properties. A new column has no total: Sum, chosen in the Total of each amount, brings the group's total row. Preview asks for the parameters as the viewer does, here Category Salami.

Datasets and parameters

A dataset is a set of rows the report reads; the items of the report each read one. In Data, + Dataset:

Data fromWhat it reads
SQLa query on the catalog (the datasources you may read, by name, on the engine you choose, as in a SQL script) or on a database connection (in the database's own dialect, with CONNECT on the connection's folder). Only a query runs, as in a SQL script; as there, the barrier is a read-only database user on the connection
Datasourcea datasource as it is, with optional filters (a column, = <> < <= > >= in contains, a value or a parameter) applied before the rows are read
Saved viewa saved view with its filters, computed fields and pivot, plus filters of its own
SQL scripta saved SQL script, its current version, on its catalog or connection: whoever changes the script changes the report. Its parameters are the report's

A parameter goes in the SQL as {name}, without quotes: Tabularia writes its value as a literal of the dialect, never as text pasted into the query. A text is quoted and escaped, a number checked as a number, a date written DATE 'yyyy-mm-dd', a list (a, b, c) for IN {regions} (an empty list stops the render and says so). A parameter written inside quotes, '{region}', is refused: «write {region} without quotes: Tabularia quotes it». Tabularia's own placeholders work too, {today-30}, {start_of_month}, read in the report's time zone.

Fields are the columns the designer last read from the data (Refresh fields): they feed the completion and the checks; the render uses what the data has. Refresh fields also counts the dataset's rows with the parameters' defaults and shows the number under the dataset: past 500,000 rows an orange warning gives the time a render will take, past REPORTS__MAX_ROWS a red one says the report will not render. Aggregate in the SQL and format in the report: SELECT region, SUM(amount) AS amount FROM orders GROUP BY region gives the report one row per region, not one per order. Calculated fields are expressions computed for every row, used as any other field.

The dataset dialog: the dataset margins, SQL on the catalog with DuckDB, a query with category and since as parameters, two calculated fields Month and MarginPct, and the fields read

The dataset of the example: the SQL with its two parameters in braces (the chips under the editor insert them), the calculated fields Month and MarginPct.

A parameter is a value the reader chooses. In Data, + Parameter:

Field
Nameletters, digits and _; not a name of Tabularia's placeholders (today, now…). In the SQL {name}, in an expression PARAM("name")
Label in the formwhat the reader sees
TypeText, Number, Date or Yes or no; Several values makes it a list
Requiredthe report does not render without a value
Defaultused when the reader gives none: a value, or for a date a placeholder resolved at every render, {today-30}, {start_of_month-1m}
ChoicesAny value, a Fixed list (one per line), or From a dataset (an expression for the value and one for the label shown)

Values arrive as text (the viewer's form, a flow group's cell, the query string of the API) and are converted and checked against the type and the choices before anything runs: a value that does not fit stops the render with the parameter's name.

A parameter that filters the data

The usual parameter takes its choices from a field of a dataset, and filters that same dataset by what the reader chooses:

  1. + Parameter: a name, regione, Choices → From a dataset, the dataset and, as Value, the field: [area].
  2. With a datasource or a saved view, Filter this dataset by what is chosen is already ticked: it adds the filter area = {regione} to the dataset (area in {regione} with Several values), and takes it away when you untick it.
  3. With a SQL or a SQL script the query stays as you wrote it: the dialog shows the line to add, WHERE {regione} IS NULL OR area = {regione}, with a button that copies it.

That is all. The list always shows every value of the field, whatever is chosen: to read it Tabularia leaves out what depends on that parameter, while the report itself applies the filter. If the parameter is not required, with nothing chosen (Any) every row stays. With Several values a SQL needs at least one value (IN {regione}), and the report asks for it; a filter on a datasource or a view lets every row through with none.

Several parameters on one dataset work the same way, and follow the order of the form: add regione, then agente, and the agents follow the region while the regions stay all there. To take the choices from somewhere else, a table of regions or a list you curate, choose another dataset as their source: the filter on the data is then yours to add. Two lists that follow each other through two different datasets are refused, by name, because neither could be chosen first.

In a SQL, Tabularia recognises the conditions on the parameter in the WHERE, the HAVING and the ON of a join, in subqueries too, and takes them for true when it reads the list. Written anywhere else, say in the columns or in a LIMIT, the parameter reads NULL for the list, as with no value; if that leaves the list empty, the form says so and what to do.

The value of a filter on a datasource or a view is chosen in the dataset's dialog: A value written here, or a parameter of the report, by its label and name. A parameter of several values sets the operator to in, one of a single value takes it back to =, and the dialog says so. Written by hand, {regione} and PARAM("regione") both become the parameter, and a name that does not exist is an error on the spot, with the name you probably meant. Renaming a parameter carries its filters and its SQL with it (a saved SQL script is its own text: the designer tells you to change it there); removing one takes its filters away and tells you which SQL still writes it.

Expressions

Expressions are a small language of Tabularia's own, close to spreadsheet formulas. The server parses and evaluates them, never runs them as code. They go wherever a value is computed: a text that starts with =, a column's value and total, a group's key and label, a rule's condition, a chart's axis and series, a KPI, a table's Rows where and its sort keys, an item's Shown when, a calculated field.

WriteFor
[ricavo]a field; ]] for a ] in its name
"text", 12.5, TRUE, FALSE, NULLvalues; "" for a quote inside a text
{today-7}a date placeholder, in the report's time zone
+ - * /arithmetic; a date plus a number moves it by days, a date minus a date is days
&joins texts: "Total " & [region]
= <> < > <= >=comparisons, one at a time: join two with AND
AND OR NOTconditions
SUM(...)functions, in capitals or not

!=, ==, &&, ||, ^, % and texts in single quotes are not part of the language: the check says what to write instead. NULL goes through arithmetic and comparisons (NULL + 1 is NULL), aggregates skip it, a division by zero is NULL, & writes it as nothing, and a condition that is NULL is false.

Where an aggregate counts. An aggregate works on the rows of the place it sits in: in a group's total row, the group's rows; in the grand total, the table's; in a text, a KPI or a chart of the body, the rows of its dataset. A dataset named as the last argument, SUM([amount], "orders"), reads that whole dataset instead, which is the only way to show data in the page header and footer. A field written outside a row (in a group's total row) is the value of the group's first row.

The properties of a KPI whose value divides the sum of margine by the sum of ricavo, with the completion open: the fields of the dataset with their types, then the functions, SUM highlighted with its signature and what it does

The completion (Ctrl+Space, or [ for the fields): the dataset's fields with their types, the calculated ones, then the functions with their arguments; the highlighted one says what it does.

Functions

Every function with its cases and an example on the sample data, with the result it prints, is in Report functions, with recipes for a real report: the margin percentage, the growth on the month before, the share of a level you choose, the year before, a value from a price list, the sum of a period, running totals, thresholds, dates in titles.

FunctionArgumentsGives
SUM, AVGvalue, [dataset or group]the sum, the average of the values, NULLs left out
MIN, MAXvalue, [dataset or group]the smallest, the largest value
COUNT[value], [dataset or group]how many rows; with a value, how many are not NULL
COUNTDISTINCTvalue, [dataset or group]how many different values
FIRST, LASTvalue, [dataset or group]the value of the first, the last row
SUMIFcondition, value, [dataset or group]the sum over the rows where the condition is true: SUMIF(YEAR([day]) = 2025, [amount])
COUNTIFcondition, [dataset or group]how many rows have the condition true: COUNTIF([state] = "open")
PERCENTaggregate, [group]the aggregate as a share of the same one level up, a row of its group, a group of the whole, or of the group named: PERCENT(SUM([amount]), "region")
RUNNINGaggregatethe running total within the group, row after row: RUNNING(SUM([amount]))
PREVIOUSvaluethe value on the row above in its group; in a total, on the group before: [amount] / PREVIOUS([amount]) - 1
LOOKUPkey, key field, result, [dataset]the result on the rows whose key field equals the key: a field of the first, an aggregate of them, NULL if there are none: LOOKUP([code], [sku], [price], "prices")
ROWNUMBERthe number of the row within its group, from 1
IFcondition, value if true, [value if false]one value or the other; without the third, NULL
IFScondition1, value1, condition2, value2, …, [else]the value of the first true condition, else the last argument
ISNULLvaluetrue when the value is missing
COALESCEvalue1, value2, …the first value that is not missing
ROUNDnumber, [decimals]rounded half away from zero, to 0 decimals by default
ABS, FLOOR, CEILINGnumberwithout its sign, rounded down, rounded up
POWERnumber, exponenta number raised to the exponent
UPPER, LOWER, TRIM, LENtextin capitals, in lower case, without spaces at its ends, its length
LEFT, RIGHTtext, countthe first, the last characters
MIDtext, start, countpart of a text, from a position (1 is the first character)
REPLACEtext, old, newevery occurrence of old replaced
CONTAINS, STARTSWITHtext, parttrue when the text contains, starts with, the part
TEXTvaluethe value as text, as the report would write it
CONCATvalue1, value2, …the values joined as one text
DATEyear, month, daya date; an impossible one (30 February) is NULL
YEAR, MONTH, DAYdatethe year, the month (1 to 12), the day of the month
WEEKDAYdatethe day of the week, 1 (Monday) to 7 (Sunday)
DATEADDunit, count, datethe date moved by "d" days, "w" weeks, "m" months or "y" years; 31 January plus a month is the last day of February
DATEDIFFunit, start, endwhole days, weeks, months or years from start to end
TODAY, NOWtoday's date, the date and time of the render, in the report's time zone
FORMATvalue, patterna number or a date as text: FORMAT([day], "d MMM yyyy") (the patterns are below)
PAGE, PAGESthe page's number and how many pages there are; only in a text, joined with &
PARAM"name"the value of a parameter, the name in quotes
REPORTthe report's name

Arguments in brackets are optional. An aggregate takes one value at a time: write SUM([a]) / SUM([b]), not an aggregate inside another.

Formats

The Format of a text, a column, a chart or a KPI, and the second argument of FORMAT(), is a pattern. , and . mark where the separators go; the characters written are those of the report's language.

PatternValueEnglishItalian
0202401202401202401
#,##010478825.410,478,82510.478.825
#,##0.001234.51,234.501.234,50
0.0%0.245324.5%24,5%
€ #,##02569181€ 2,569,181€ 2.569.181
#,##0 pcs12001,200 pcs1.200 pcs
#,##0;(#,##0)-1500(1,500)(1.500)
#,##0;-#,##0;-0--
d MMM yyyy2026-03-055 Mar 20265 mar 2026
MMMM yyyy2026-03-05March 2026marzo 2026
dd/MM/yyyy2026-03-0505/03/202605/03/2026
EEEE d MMMM2026-03-05Thursday 5 Marchgiovedì 5 marzo
HH:mm14:3014:3014:30
  • Numbers: 0 a digit always written, # one written only when needed, % multiplies by 100; text before or after the digits as it is, in double quotes when it holds 0, # or %. Up to three sections separated by ;: positive, negative, zero. Rounding is half away from zero. French groups with a space (1 234,50); Spanish starts grouping at five digits (1234, but 12.345).
  • Dates: yyyy yy the year, MMMM MMM MM M the month (name, short name, two digits, one or two), dd d the day, EEEE EEE the day of the week, HH H the hour, mm m the minutes, ss s the seconds. M is the month and m the minute. Text goes in single quotes, '' for an apostrophe.
  • Without a format a whole number is written as it is (a year or a month code is never «2,026»), other numbers with separators and up to two decimals, a date in the language's own way (d MMM yyyy in English, dd/MM/yyyy in Italian, Spanish and French, dd.MM.yyyy in German), a yes/no as Yes or No.
  • In Excel a cell keeps the raw number or date, with the same pattern as its number format, so it can still be summed; a pattern that does not say how to write negatives writes them in red.

Tables and matrices

A table lists the rows of its dataset and grows over as many pages as they need, its header repeated at the top of every page. Its properties:

Section
Datathe dataset; Rows where, a condition that keeps some rows; Order of the rows, keys with their direction
Columnsfor each: Header, Value (an expression), Width (millimetres, or automatic), alignment, Format, Total (None until you choose one: Sum, Average, Minimum, Maximum or Count of the column's value, or an Expression…, an aggregate of your own such as SUM([margine]) / SUM([ricavo]); printed in the groups' total rows and in the grand total), the style of its cells and its Conditional formatting
Groupsup to six levels, each with Group by (an expression), Name in expressions (how an aggregate names the group; a group by a field already has its name), Label, Order, Page break (none, between groups, after each group), Header row, Total row and Keep the last rows with their total
LayoutShow the rows (off: the groups' totals only), Grand total at the end, Header on every page, Alternate row shading
Sheet name in Excelthe table's sheet when the report is downloaded as Excel
On screenSort by a column with a click on its header, Close and open the groups: what the reader may do in the viewer

No column gets a total by itself: a field dragged onto a table, a column added and a new table have none, because a price or a discount does not add up and only you know which values do. The table's first total, when no row is there to print it, turns on Grand total at the end; turn it off if you want the groups' totals alone. A table where no column has a total prints no total rows at all, not even empty ones, and the designer draws none (a group with its Header row off keeps its Total row: it is all that names the group).

A group's total row reads «Total» and the group's label, across the columns before the first one that has a total; when the first column has a total too, the label takes a row of its own above the values. A group whose value is missing is named «(blank)». A group that goes on over the next page repeats its label there. Keep the last rows with their total never leaves a total alone at the top of a page. A table with no rows says «No data», with no total of nothing.

Columns whose widths add up to more than the table are narrowed to fit it, and a value wider than its column (a long number in a narrow column) would run into the next one: the preview and the viewer say both, naming the table and the columns, so the design can be fixed. So does an item that goes past the edge of the paper, and a KPI whose value is wider than its card.

Conditional formatting gives a column's cells a style when a condition about their row is true: [MarginPct] < 0.3 in red and bold. A column may have several rules; when more than one holds, the later ones win where they differ. Rules colour the rows' cells, not the totals.

A matrix is a table with One column per value of an expression (at most 60 columns): YEAR([Month]) gives a column per year. Its groups make the rows, Value of the cells is an aggregate of the rows that meet there (SUM([margine])), Total column, which a new matrix leaves off, adds the row totals, and Grand total at the end adds the column totals.

The columns come in the order of the data: each value where it first comes in the rows the table reads, after its Rows where and its Order of the rows, as a chart's categories do with «In the data's order». With levels of columns, each one's values follow the same rule inside the value above them. So the ORDER BY of the dataset's SQL decides: with ORDER BY anno_mese, a column per FORMAT([Month], "MMM") reads Jan, Feb, Mar. A value that the first rows do not have comes where it first shows up, so sort by what makes the columns, before the rest. A query without ORDER BY has no order the database promises, and the columns may change from one run to the next: order the dataset, or the table. A missing value is a column too, «(blank)», in its place.

The last page of the example PDF: the group Salumi stagionati continued from the page before with its label repeated, the margin percentages under 30% in red, the group's total and the grand total, then the matrix of the margin by category and year with its totals

Page 3 of the example: the group carried over from page 2 repeats its label, the rule paints the margins under 30% red, the group's total and the grand total follow; then the matrix, one column per year.

Charts and KPIs

A chart reads its dataset and aggregates it: Along the axis says what the bars, points or slices are ([region], FORMAT([day], "yyyy-MM")), and each Series is an aggregate. The server draws the charts, the same in the PDF, on screen and (as images) in the email.

KindDraws
Bar, Columnhorizontal bars, vertical columns; stacked if asked
Line, Areaone line per series along the axis
Pie, Donuteach category's share; values below zero are left out
Combobars and lines together, each series Drawn as bars or line, on the left or the right axis, with a Format of its values of its own (amounts in euros on the left, a percentage on the right); its axis writes its numbers the same way
Waterfallhow each category adds to or takes from a total: rises in green, falls in red, the total last
Funnelstages from the largest to the smallest
Treemapeach category as a rectangle sized by its value; with One series per value of, nested
Heatmapa grid of the axis by One series per value of, the colour by the value
Scatterone point per category (X the category, Y the aggregate), or one per row with a value that is not an aggregate
Gaugeone value on an arc, with Minimum, Maximum, a Target and coloured Bands
Bulleta bar per category against its Target, over its Bands

The options: One series per value of (the first series repeated for each value of a field, up to 24), Stacked, Values on the chart, Legend, Order and Top (the N largest values of the first series, the rest gathered as «Other»), Minimum, Maximum, Target (a number or an aggregate), Bands (their limits, 50, 80) and a Format for the values. With Order «In the data's order» the categories come as they first appear in the dataset's rows, so the ORDER BY of its SQL decides: sort the query by the axis (ORDER BY anno_mese) and the months follow one another in time, whatever they print as ("MMM yy" too); the series of One series per value of and a KPI's trend follow the same order. «By value, smallest first» and «By value, largest first» sort the categories by the first series' value instead. With Top, the N largest are kept and shown in the order chosen. A chart draws at most 2,000 categories: set Top, or aggregate.

A KPI card shows one aggregate in large type, with its Label and Format; Compared with, another aggregate, adds the change in percent with an arrow and a colour; Show the trend under the value draws a sparkline of an aggregate Along a field.

The new kinds (combo, waterfall, funnel, heatmap, gauge, bullet, the KPI card) are in the Viewer's and the assistant's charts too: see Preview and analysis.

The viewer

A click on a report in Explore opens it for reading. The bar at the top has one field per parameter, as the report defines it: a list for those with choices («Any» for an optional one), a date picker, Yes or No, several values for a list; * marks a required one. The fields open with the defaults. An optional field emptied has no value, whatever its default; a required one asks for a value. View report renders it.

The viewer of Monthly margin: the bar with the parameters Category (Prosciutti) and From (01/01/2025), View report, and the first page at 100% with the title Monthly margin — Prosciutti, three KPI cards and the chart

The example with Category Prosciutti: the title says which category, and the KPIs and the chart count only its rows. Above, PDF, Excel, Copy API link, the versions and Edit.

The pages are those of the PDF, drawn by the same renderer, with Page to move between them (Page Up, Page Down), the zoom (+, −, 0 to fit the width) and Fit. Where the designer allowed it, a click on a column's header sorts the table by it (again for descending), unless its value depends on that order, as PREVIOUS does, and a click on a group's label closes or opens it; Reset view goes back to the report's own order with every group open. PDF and Excel download the report as shown: with the values chosen, the sort and the groups closed on screen. The API, the flow groups and the notebooks render it in its own order.

The viewer of Margin growth by month: the year of From is typed over, 2025 becomes 2026, View report renders one page; a click on the header vs month before sorts each category's months by their growth, the arrow beside the header; a click on the label of Insaccati cotti closes the group to its total row; Reset view appears in the bar

«Margin growth by month» from January 2026: a click on the header vs month before sorts every group by it (the running revenue follows the new order), a click on the group's label closes it to its total; Reset view puts the report back as designed.

The other buttons: Copy API link (the address a script downloads the report from, with the values on screen that are not the defaults, so a default such as {today-30} keeps moving with the calendar, and the same as a Python or a curl example), the versions (every save; who may edit restores an older one, which becomes the newest), and Edit, which opens the designer for who may edit. A report that cannot be rendered says why in words: a value that does not fit, a dataset you may not read, too much data, too many pages, the time limit.

The Reports tab

Analysis → Reports in the top bar lists every report you may open, from every folder, the most read first; Analysis also holds the Viewer. Each row shows the report's name and description, how many datasets and parameters it has, its folder with the folders above it, its readers, when it was last opened, and when it was last changed, with its version and its author. A click on a row opens it in the viewer; on the right, Open, Edit in the designer (with EDIT on its folder) and Copy API link, the same as in the viewer, with the report's defaults.

The Reports tab: five reports of the folder Sales reports and of its subfolder Channels, the most read first, each with its description, datasets and parameters, its folder, a bar with its readers in the last 30 days (5 people, 4, 3, 2, 1), when it was last opened, and when it was changed, with its version and author; on the right Open, Edit in the designer and Copy API link

The tab as an analyst with EDIT on Sales reports sees it: «Monthly margin», opened by five people this month, comes first.

Readers · 30 days is what the default order, Most read, follows: the people who opened the report in the viewer in the last 30 days, with a short bar against the most read report of the page. Under it, the openings in those days and the openings ever. A person counts once a day per report: reloading the page, choosing other values or opening it from three tabs is still one opening. The day is the installation's (APP__TIMEZONE). At the same number of people the report opened more times comes first, then the one opened more times ever, then the one changed last. Name and Recently changed order the list the other ways.

Only the viewer counts: a download through the API, a flow group's email and a notebook render the report without anyone reading it on screen, and the designer's preview is its author at work. Who opened a report is kept for 30 days and then removed every hour; the report keeps its total and the moment it was last opened. The list never says who read a report, only how many.

The search box looks in the names, the descriptions and the folders: finance finds the reports of a folder Finance and of every folder inside it. The list shows 50 reports a page. The order, the search and the page are in the address, so a link or a reload gives the same list.

Analysis then Reports in the top bar: the list, the most read first; the order changed to Name and back to Most read; channels typed in the search finds the two reports of the folder Sales reports › Channels; margin finds three reports by their names and descriptions; a click on Margin growth by month opens it in the viewer

From the Analysis menu: the most read first, then by name; the search finds by folder (channels) and by name or description (margin); a click on a row opens the report.

PDF, Excel and the email

FormatWhat it is
PDFthe page as designed: positions exact to a tenth of a millimetre, the font embedded, the header and footer on every page, «Page X of Y». The same data gives the same file
Excelone sheet per table and matrix (an AI comment has no place in it), named by Sheet name in Excel: the groups as outline levels (closed and opened with Excel's + and −), the subtotals and totals as SUBTOTAL formulas with their values, numbers that stay numbers in the report's formats (a whole number past 16 digits, an id, and a date before 1900 are written as text: a cell of Excel would change the first and cannot show the second), the header frozen and repeated on printed pages, a page break per group where the report has one; one more sheet lists the parameters' values. A text is never turned into a formula
Emailthe summary in the body of a Pushmail email, 640 pixels wide: the report's texts, its KPI cards, its charts as images and the totals of every table and matrix (below); the detail goes as the attached PDF or Excel

Page 1 of the example PDF: the title, the printing date, the line in the accent colour, three KPI cards with sparklines, the combo chart of revenue and margin by month, and the table grouped by category with the first group's total; the report's name and Page 1 of 3 in the footer

Page 1 of 3 of the example's PDF with every category, from January 2025.

The summary in the email

The summary follows the body of the report, in its order:

  • its texts, its KPI cards, its charts and its AI comments, each with its line;
  • each table as its totals: the total row of each of its outermost groups and the grand total, with the columns that have a total; a matrix as its rows and its total column. Five columns of values at most fit the width of an email: a wider matrix shows its total column alone, a wider table its first five, and says how many more are in the attached report;
  • a table with nothing to total (no group with a total row, no grand total) is left out, and so is the row of texts right above it that introduces it, its title; a table with no text above it is introduced by its Sheet name in Excel, when it has one.

Gmail cuts a message longer than about 100 KB: a long summary shows fewer groups per table («… and 40 more: the whole report is attached»), then leaves out the last tables («2 more tables are in the attached report»). The same summary goes as plain text, a line per total («Total Salami — Revenue 4,854,308 · Margin 1,299,924»), for a mail client that shows no HTML.

Sending it by email

A report is sent only by a flow group: a Pushmail cell attaches it (PDF or Excel, the values of its parameters, the file's name) and can put its summary in the email's body. The cell runs after the steps before it, so the email leaves right after the data it shows has been refreshed, and not at all when a refresh failed.

The email in Mailpit: from Tabularia Reports to sales-team@example.com, subject Monthly margin 2026-10-30, the message, then the report's summary: the title, the three KPI cards, the combo chart, the totals of each category and the margin by category and year, with the PDF attached

The example sent by a flow group to a test mail server: the cell's message, then the report's summary; the whole report is the attached PDF.

From scripts: the API and notebooks

Any script downloads a report with an API token that may read, with the values of its parameters in the query string:

curl -fG -H "Authorization: Bearer $TABULARIA_TOKEN" -o monthly_margin.pdf \
  --data-urlencode format=pdf --data-urlencode category=Salami --data-urlencode "since={start_of_year}" \
  https://tabularia.example.com/api/reports/12/render

The formats, the errors and a Python example are in the API's Download a report; in a marimo notebook, tabularia.report() does the same in one line.

AI comments

An AI comment is a short text written by the configured AI model on the numbers of the KPIs, charts and tables you pick, such as what moved and what stands out. The numbers never depend on it: a provider that is down, a budget that is spent or a mode that forbids it costs the comment only, and the page says so.

To add one, drag AI comment from Insert onto the page, and in its panel write what you ask of it (at most 1,000 characters; empty, it says what stands out) and tick what it reads, from 1 to 10 elements. Its place and size are in millimetres, as for any item, and it is at least 40 mm wide. A longer text makes the box grow and pushes down what is under it, and a very long word breaks at the edge. Try it shows what it writes: it is the only moment the designer asks the model, and the preview shows what Try it wrote and never asks.

What it readsthe title and the numbers of the elements it reads, as the page writes them: a KPI's value and change, a chart's categories and series, a table's groups and totals, a matrix's rows, with your instruction, in the report's language
What it never readsa row. A table's detail rows and a scatter that is not aggregated stay out, though the page shows them. An element with only those is left out, and with nothing left no call is made: the panel says so. It has no tool to query with, and it reads nothing else
When it writeswhen the report is opened, or a PDF or a summary made, if those numbers have no comment. The same numbers make one call for everyone, for a day (AI__COMMENT_KEEP_SECONDS); other values of a parameter or data that changed make another
How long it waitsat most AI__COMMENT_WAIT_SECONDS (30), with the render behind it. A report without the element waits for nothing, asks nothing, and is the file it always was
Who payswhoever causes the call, under the daily ceilings: who opens the report, the account of an API token that may write, the group's author for a Pushmail cell. A read-only API token, a notebook and an observer cause no call, and get what is kept
Excelit has no place in a workbook, so an Excel asks the model nothing, and the workbook is the same with or without one

On paper and on screen it is always marked: Written by AI · the model · numbers of the moment they were read, and that it may be wrong. No setting takes that away. The text is plain text, at most 1,500 characters, with no link: nothing in it is ever read as markup, in the PDF as in the email.

When it cannot write, the document goes out all the same, with one line in its place: that the comment is not available, that the day's spending limit has been reached, that this installation sends no data to AI models (AI__METADATA_ONLY: nothing is sent and no number is read for it). Past the wait the text is kept for the next one who asks, and this document says it is not available: paper does not speak of what is on its way.

The numbers are those on the page. The comment and the pages read the same rows, once from the source for that document. A report also never lends its author's rights: a reader who may not read a dataset gets no pages, as for any report, and so no text. The text kept for someone who reads more is found only by whoever has just read the same numbers.

What its model reads goes where the administrator said: AI in Tabularia.

With whose rights

A report never lends its author's rights. What it reads is checked dataset by dataset with the rights of whoever it is rendered for:

RenderedReads with the rights of
in the viewer, in the designer's previewwhoever opens it
by a flow groupwhoever runs the group: the author of its schedule, for a scheduled run, checked again at every send
through the APIthe token's account
in a notebookthe notebook's session, as its reads

A dataset the reader may not read stops the render with its name, as one that does not exist (The dataset «orders»: there is no datasource «Salaries» you can read): two people may open the same report and one of them gets an explanation instead of the pages. SQL on a connection needs CONNECT on the connection's folder.

Because a scheduled group sends with its author's rights, someone else who changes what a report reads switches off the schedules of the groups that send it: a dataset added, or one whose SQL, connection, datasource, view or filters changed; a new default of a parameter the group's cell does not set itself; a change to a SQL script or to the filters, computed fields or pivot of a saved view that the report reads; an AI comment added, or one that reads something else or is asked something else, since it spends the author's budget. The audit log says why, and the author schedules the group again after looking. A change of layout, or one made by the schedule's own author, keeps them. A report that a flow group sends cannot be deleted until it is taken out of the group's cells.

The other way round, what a report reads by reference (a saved view, a SQL script, a datasource chosen as such, a connection) cannot be deleted while the report's current version reads it: the refusal names the reports you may see and counts the others. A datasource named in a SQL goes by its name, and is not looked for: the report then says it cannot find it.

The sample reports

The sample data ends with two finished reports in the Sample folder, to open and to take apart: Analysis → Reports lists them, and Edit shows how each is made. Nothing in them is typed by hand: each number is read from the tables of Sample / Private tables, written by the sample flows. Whoever reads them needs VIEW on Sample.

Margin review

The document for the directors: where the revenue and the margin of a period stand, against the same months a year earlier. It opens on 2025, in two pages; with one Category, in one.

Page 1 of Margin review: a sentence with the revenue and the margin against a year earlier, four cards with arrows and trends, a combo chart of revenue and margin percent by month, the margin percent by category, and the table by category and month with its totals

Look atTo see
Category, From, Tothree lists on one dataset, months, each filtering it: From shows months with the label FORMAT([Month], "MMMM yyyy") and the value [anno_mese], and To starts where From does, since a list follows those before it
The dataset historythe same table with the category only, so that a year earlier is there to compare with. A second dataset is how a filter leaves out what a comparison still needs
The cards' Compared withSUMIF([anno_mese] >= PARAM("first_month") - 100 AND [anno_mese] <= PARAM("last_month") - 100, [ricavo], "history"): the same months a year earlier, and inside an IF nothing when the data does not go back that far, rather than half a period
The sentence on topa Text that starts with =, joining FORMAT() of aggregates of two datasets
The table's Shareas Value PERCENT(SUM([ricavo]), "categoria"), a month's part of its category; as Total PERCENT(SUM([ricavo])), the category's part of the whole
vs month before[ricavo] / PREVIOUS([ricavo]) - 1, with a Rule that writes the falls in red. A category's first month has none, and the column offers no sort: its value depends on the order
The header=REPORT() & IF(ISNULL(PARAM("category")), "", " — " & PARAM("category")), and the months of the parameters turned into dates

The data stops on 15 July 2026, so July 2026 is half a month, in the chart and in a comparison.

Agent scorecard

The document for a sales manager: how the agents of an area stand, on revenue and on the contacts of the CRM. Three pages for all of them; choose an Area and the Agent list holds only its agents.

The viewer of Agent scorecard with Area Nord-Ovest: the lists Area and Agent above the page, four cards with revenue, orders, revenue per order and field visits, a scatter of revenue against visits and the contacts by kind

Look atTo see
Area, Agenttwo lists that follow each other: both read and filter the dataset agents, in the order of the form
The calculated fields of agentsVisits and Contacts: LOOKUP([area] & [agente], [area] & [agente], [visita], "crm") reads a second table, with the area in the key as two agents may share a name. Then they are fields like any other, in a card, a chart, a total
Size, another calculated fieldIFS([ricavo] >= 1.2 * AVG([ricavo]), "Over 120%", …): an aggregate in a calculated field is over the dataset, as chosen in the form
The table's Of the area[ricavo] / SUM([ricavo], "area"), a row's part of its group; the same in the Total, with the grand total left empty because it is in no group
Of contactstwo Rules, in red under 18% and in green from 22%
The matrix under the tableOne column per value of [Size], the columns in the order of the rows, which the table sorts by revenue; Shown when COUNT() > 1 takes it away for one agent
Contacts by area and kinda stacked chart on crm, a dataset with the same two filters: five series, one per field of the table

To add an AI comment to one of them, as above, duplicate it first: Insert → AI comment, then tick the cards and the table it reads. They come without one because it needs a model: on an installation with none it would say only that it is not available.

A report you change stays as you left it, whatever runs later: the sample container looks it up by name and writes nothing over it. One you delete comes back as it was the next time the container runs.

Example: a monthly margin report

With the sample data (the «Sample» folder): a report on the datasource «Margin by Category and Month» (in Sample / Private tables, written by the sample flow of the same name), with the revenue, cost and margin of each category month by month, a chart and three KPIs on top, and a choice of category and of the first month.

  1. The report. In Explore, in a folder of yours (here Sales reports), New → Report. In the designer, write Monthly margin over «New report». In Page setup keep A4 portrait with margins 15 · 12 · 15 · 12, set the language English, the time zone Europe/Rome and the font size 8.
  2. The first month. Data → + Parameter: name since, label From, type Date, Required, default {start_of_year-1y} (the first of January of last year, at every render).
  3. The data. + Dataset: name margins, SQL, Runs on Datasources, engine DuckDB:
    SELECT categoria, anno_mese, ricavo, costo, margine
    FROM "Margin by Category and Month"
    WHERE anno_mese >= EXTRACT(YEAR FROM {since}) * 100 + EXTRACT(MONTH FROM {since})
    ORDER BY categoria, anno_mese
    

    {since} becomes a date, and anno_mese holds months as 202501. Add two Calculated fields: Month, DATE(FLOOR([anno_mese] / 100), [anno_mese] - FLOOR([anno_mese] / 100) * 100, 1), the first day of the month as a date; and MarginPct, IF([ricavo] = 0, NULL, [margine] / [ricavo]). Add: the fields are read from the data.
  4. The category. + Parameter: name category, label Category, type Text, not required, no default, Choices → From a dataset: margins, value and label [categoria]. margins is SQL, so the dialog shows the line that filters it by what is chosen: Add, then open margins and put that line in its WHERE, joined to the other condition with AND:
    SELECT categoria, anno_mese, ricavo, costo, margine
    FROM "Margin by Category and Month"
    WHERE ({category} IS NULL OR categoria = {category})
      AND anno_mese >= EXTRACT(YEAR FROM {since}) * 100 + EXTRACT(MONTH FROM {since})
    ORDER BY categoria, anno_mese
    

    With no category chosen {category} is NULL and every category stays. The list of categories reads the same query without that condition, so it always holds them all.
  5. The page header. A new report starts with a title and a line here, and a page number in the footer. Click Page header beside the page, select the title and the line and press Del, then in Insert a Text, Expression: ="Monthly margin" & IF(ISNULL(PARAM("category")), "", " — " & PARAM("category")), size 14, bold. Under it a text ="From " & FORMAT(PARAM("since"), "MMMM yyyy") & " · revenue, cost and margin of the sample orders", size 8, grey; on the right the ready-made Date and time of printing; a Line across, 0.4 mm, in the accent colour. Header height 17 mm.
  6. The KPIs. Click Body, then three KPI cards side by side, 58 mm wide, on margins: Revenue, SUM([ricavo]), format € #,##0; Margin, SUM([margine]), € #,##0; Margin %, SUM([margine]) / SUM([ricavo]), 0.0%. Each with Show the trend under the value along [anno_mese], the same value.
  7. The chart. A Chart, kind Combo, the full width under the cards, title Revenue and margin by month: along the axis FORMAT([Month], "yyyy-MM"), series Revenue, SUM([ricavo]), drawn as bars, and Margin, SUM([margine]), as a line; format #,##0.
  8. The table. A Table under the chart. In Data, drag Month, ricavo, costo, margine and MarginPct onto it. Open ricavo, costo and margine and choose Sum as their Total. Name the headers Month, Revenue, Cost, Margin, Margin %; give Month the format MMMM yyyy and the amounts #,##0; Margin % the total Expression…SUM([margine]) / SUM([ricavo]), the format 0.0% and a Rule: when [MarginPct] < 0.3, red and bold. Order of the rows: [anno_mese]. + Group: [categoria]. Grand total at the end is already on, from the first total you chose; tick Alternate row shading and both On screen options. The GIF above shows the drag and the group.
  9. The matrix. Under the table a text Margin by category and year (size 10, bold) and a Matrix: rows grouped by [categoria], One column per value ofYEAR([Month]), Value of the cells SUM([margine]), format #,##0; tick Total column. The years come in the order of the rows, which the query sorts by anno_mese within each category.
  10. The footer. Click Page footer, select the page number it started with and press Del, then add the ready-made The report's name on the left and Page number of total on the right.
  11. Preview and look at it, then Done: the report is saved and opens in the viewer. It has three pages; with Category Prosciutti, one.
  12. PDF downloads it. To send it every month, put it in a flow group after the refresh of its data: step 1 the flow «Margin by Category and Month», step 2 a Pushmail cell with the report as PDF, the file name monthly_margin_{today:yyyyMM} and Summary in the email body; then Schedule the group, for example 0 7 1 * *, at seven on the first of every month.

Who reads it needs VIEW on Sales reports and on Sample / Private tables, where the datasource is; who sends it from a flow group needs, besides, what the group and the Pushmail cell need.

Permissions

On the report's folderYou can
VIEWopen it, choose its values, read it on screen, download it as PDF or Excel, copy its API link, read its versions
EDITcreate it, design and save it, rename, move, duplicate and delete it, restore a version

Whoever has VIEW only is taken to the viewer when they open the designer's address. Administrators see every report. Reports show in Explore, with their own icon, in the search and in the Reports tab. Every save, rename, move, duplicate, delete and restore is in the audit log (report.create, report.update…), and so is every render (report.render), with its format, the values of its parameters, the outcome and where it came from: the interface, the API, a notebook or a flow group.

Limits and good to know

  • A dataset reads at most REPORTS__MAX_ROWS rows (3,000,000 by default): one more stops the render and names the dataset. The query is cut at the source, so a SELECT * on a big table costs no more than that many rows. A report is a document, not an export: aggregate in the SQL where you can. The report works out its groups, totals, charts and KPIs over the rows in columns, with DuckDB: a million rows grouped with their totals take a second or two and some 500 MB of the worker's memory, a thousand totals almost nothing. Shown row by row they would not fit the pages: a table with more rows to show than its pages can hold stops at once and says so. A render makes at most REPORTS__MAX_PAGES pages (200); its layout runs in a process of its own with REPORTS__RENDER_MEMORY_MB of memory (1024: some 200 pages of tables) and REPORTS__RENDER_SECONDS of time (120). See the configuration.
  • Charts are drawn by a chart service: the chart-renderer service on Docker Compose, a sidecar of the worker pods on Kubernetes. When it does not answer, a report with charts fails and says so; the others still render.
  • At most 20 datasets and 50 parameters per report, six levels of groups per table, 60 columns per table or matrix, 2,000 categories per chart, an expression of 4,000 characters, an image of 512 KiB.
  • The screen is drawn from the same pages as the PDF; sorting and closing groups render it again, which takes as long as a render.
  • No subreports and no Word output. A report has no lineage of its own: it writes no dataset. Its schedule is that of the flow group that sends it.

Edit this page on GitHub