Standard Power BI reports weren't designed for invoices, statements, or print-ready documents — Paginated Reports were. This complete lesson teaches you how to design pixel-perfect, parameter-driven paginated reports from scratch using Power BI Report Builder, covering layout, expressions, page breaks, and automated PDF export.

Your finance team emails you on a Tuesday afternoon. They need 4,000 customer invoices generated, each one showing the correct account details, line items, and totals — formatted to match the company letterhead, printed cleanly without clipping, and exported to PDF for distribution. They need it by end of week. You open Power BI Desktop, and then you pause. Because you know that a standard Power BI report — the kind with slicers and bar charts — is completely wrong for this job.
This is the scenario that Paginated Reports were built for. Where standard Power BI prioritizes interactive exploration of data, Paginated Reports prioritize precision output. They repeat across pages correctly. They respect print margins. They handle sub-reports, conditional formatting at the cell level, and multi-page parameter-driven documents that would be an engineering nightmare in any other tool. If you've been treating Paginated Reports as a niche add-on you don't need, this lesson will change that perspective.
By the end of this lesson, you'll have the skills to design production-ready paginated reports from scratch — invoices, account statements, and operational tabular reports — using Power BI Report Builder. You'll understand the rendering model, master the layout controls that make pixel-perfect output possible, and know how to handle the real-world complexities that trip people up: page breaks, dynamic expressions, grouped data, and parameter-driven filtering.
What you'll learn:
This lesson assumes you're comfortable with Power BI Desktop at an intermediate level — you understand data modeling, DAX basics, and publishing reports. You should also have:
If you've never opened Report Builder before, that's fine — we'll walk through it from the ground up. But this lesson won't slow down to explain what a DAX measure is or how a data model works.
Before touching the canvas, you need to understand what makes paginated reports fundamentally different from standard reports. This isn't just a UI difference — it's a completely different rendering philosophy.
Standard Power BI reports use a flow-based canvas. Visuals are sized relative to the viewport, data volumes affect visual sizing, and the whole experience is designed around a browser window. If you have 10,000 rows in a table visual, Power BI will paginate it lazily in the browser.
Paginated Reports use a physical page model. You define exact page dimensions (like 8.5 × 11 inches for US Letter), exact margins, and every element on the canvas has a precise X/Y coordinate and fixed width/height in inches or centimeters. When you render this report, the engine calculates exactly how many physical pages are needed to display the data, and it ensures that every page is perfectly formatted.
This is why paginated reports are called "paginated" — they were designed from day one to produce output that maps directly to physical pages, whether those pages are printed paper or PDF pages.
The underlying format is RDL (Report Definition Language), an XML-based specification that Microsoft has used since SQL Server Reporting Services (SSRS). Power BI Paginated Reports are essentially SSRS reports that publish to the Power BI service. This lineage matters because it means the expression language, data region types, and rendering behavior all come from a battle-tested enterprise reporting platform.
Key Insight: If you've used SSRS before, Power BI Paginated Reports will feel immediately familiar. Most SSRS reports can be published to Power BI Premium with minimal modification. The Report Builder tool is nearly identical.
Everything in a paginated report layout is built from three primitive types:
Understanding that a Table is really just a structured collection of textboxes bound to a dataset — and that a List is a free-form repeating container — unlocks a lot of design flexibility.
Open Power BI Report Builder. The interface will feel different from Power BI Desktop. You'll see a ribbon at the top, a design canvas in the center (with a white rectangle representing your page), a Properties pane on the right, and a Report Data pane on the left.
Select File > New and choose Blank Report. Before designing anything, configure your page:
8.5in and Height to 11in for US Letter (or 21cm × 29.7cm for A4)0.5inThe design canvas now represents your printable area: 7.5 × 10 inches.
In the Report Data pane on the left, right-click Data Sources and select Add Data Source.
For a direct SQL Server connection:
AdventureWorks (or whatever matches your database)Data Source=your-server;Initial Catalog=AdventureWorksLTFor a Power BI dataset connection (connecting to a published Power BI semantic model):
Important: When you publish to the Power BI service, data source credentials need to be re-established in the service's dataset settings. Connection strings that work locally may need updating. Plan for this during deployment.
Right-click Datasets in the Report Data pane and select Add Dataset. Choose your data source, then write your query.
For our invoice scenario, here's a realistic query against AdventureWorksLT:
SELECT
soh.SalesOrderID,
soh.OrderDate,
soh.DueDate,
soh.ShipDate,
soh.Status,
soh.PurchaseOrderNumber,
soh.AccountNumber,
soh.SubTotal,
soh.TaxAmt,
soh.Freight,
soh.TotalDue,
c.FirstName + ' ' + c.LastName AS CustomerName,
c.EmailAddress,
a.AddressLine1,
a.AddressLine2,
a.City,
a.StateProvince,
a.PostalCode,
a.CountryRegion,
p.Name AS ProductName,
p.ProductNumber,
sod.OrderQty,
sod.UnitPrice,
sod.UnitPriceDiscount,
sod.LineTotal
FROM SalesLT.SalesOrderHeader soh
JOIN SalesLT.Customer c ON soh.CustomerID = c.CustomerID
JOIN SalesLT.CustomerAddress ca ON c.CustomerID = ca.CustomerID
AND ca.AddressType = 'Main Office'
JOIN SalesLT.Address a ON ca.AddressID = a.AddressID
JOIN SalesLT.SalesOrderDetail sod ON soh.SalesOrderID = sod.SalesOrderID
JOIN SalesLT.Product p ON sod.ProductID = p.ProductID
WHERE soh.SalesOrderID = @OrderID
ORDER BY sod.SalesOrderDetailID
Notice the @OrderID parameter at the end. This is how parameter-driven paginated reports work — the parameter appears automatically in Report Builder's Parameters folder once you run the dataset wizard with this query.
Now the real design work begins. We're building a one-page-per-order invoice that looks like something a professional would actually send to a customer. The layout has four main sections:
Paginated reports have a Page Header (repeats on every page) separate from report body content. Right-click on the canvas and select Insert > Page Header. The page header appears as a band at the top of the canvas, separate from the body.
In the page header, add a textbox for your company name:
#1F4E79 (a dark corporate blue)Add a second textbox to the right side of the header for the invoice label:
INVOICEBelow the company name, add a smaller textbox with the company address as static text. Below the "INVOICE" label, add textboxes for the invoice metadata — but these use expressions, not static text.
Click into any textbox and type = to start an expression. The expression editor opens. This is the RDL expression language, which is VB.NET-based.
For the invoice number textbox:
= "Invoice #: " & Fields!SalesOrderID.Value
For the order date:
= "Date: " & Format(Fields!OrderDate.Value, "MMMM dd, yyyy")
For the due date with conditional formatting — turn the text red if overdue:
= "Due: " & Format(Fields!DueDate.Value, "MMMM dd, yyyy")
Then, for the Color property of that textbox, use a conditional expression:
= IIF(Fields!DueDate.Value < Now() AND Fields!Status.Value <> 5, "Red", "Black")
This is one of the most powerful patterns in paginated reports: properties are themselves expressions. Color, visibility, font weight, border color — all of these can be driven dynamically by data.
Tip: The expression editor has IntelliSense for fields, functions, and operators. Use the Built-in Fields category in the expression editor to access globals like
Globals!PageNumber,Globals!TotalPages,Globals!ReportName, andGlobals!ExecutionTime.
In the report body (below the page header), add a rectangle to act as a container for the Bill-To section. Rectangles are great organizational tools — they group items and can be given a background color or border.
Inside the rectangle, add textboxes:
=Fields!CustomerName.Value
=Fields!AddressLine1.Value
For AddressLine2, which might be NULL:
= IIF(IsNothing(Fields!AddressLine2.Value), "", Fields!AddressLine2.Value)
But there's a problem: if AddressLine2 is empty, you still have a blank line taking up space. Use the Hidden property of the textbox with this expression:
= IIF(IsNothing(Fields!AddressLine2.Value) OR Fields!AddressLine2.Value = "", True, False)
This conditionally hides the row so the layout flows cleanly without blank gaps.
This is the central data region of the invoice. Insert a Table from the Insert menu, and drag it onto the canvas below the Bill-To block.
By default, a table has a header row and one detail row. Configure the columns:
| Column | Header Text | Expression | Width | Alignment |
|---|---|---|---|---|
| 1 | Item # | =RowNumber(Nothing) |
0.4in | Center |
| 2 | Product | =Fields!ProductName.Value |
2.8in | Left |
| 3 | SKU | =Fields!ProductNumber.Value |
1.0in | Left |
| 4 | Qty | =Fields!OrderQty.Value |
0.5in | Center |
| 5 | Unit Price | =Format(Fields!UnitPrice.Value, "C2") |
1.0in | Right |
| 6 | Discount | =Format(Fields!UnitPriceDiscount.Value, "P0") |
0.8in | Right |
| 7 | Total | =Format(Fields!LineTotal.Value, "C2") |
1.0in | Right |
The total width should equal your printable body width (7.5in). Measure carefully — overflow causes clipping in PDF output.
Style the header row: select all header cells, set background color to #1F4E79, font color to White, and font weight to Bold.
For the detail row, add alternating row colors using an expression in the Background Color property:
= IIF(RowNumber(Nothing) MOD 2 = 0, "#EBF3FB", "White")
This creates the classic striped table look that makes long line item lists readable.
Right-click on the table and select Add Footer. This table footer (different from the page footer) appears after all detail rows. Use it for the financial totals.
In the totals row, add subtotal expressions using Sum():
=Format(Sum(Fields!LineTotal.Value), "C2")
But for the invoice totals block — SubTotal, Tax, Freight, Grand Total — these come from the header-level fields that are the same across all rows. Place these below the table in individual textboxes:
= "Subtotal: " & Format(First(Fields!SubTotal.Value), "C2")
= "Tax: " & Format(First(Fields!TaxAmt.Value), "C2")
= "Freight: " & Format(First(Fields!Freight.Value), "C2")
= "Total Due: " & Format(First(Fields!TotalDue.Value), "C2")
First() is an aggregate function that grabs the first value of a field across all rows — appropriate here because SubTotal is duplicated across every detail row (due to the JOIN structure), and they're all the same value.
Warning: Aggregate functions are required whenever you reference dataset fields outside a data region or in a context where multiple rows might exist. Forgetting this is one of the most common errors new paginated report authors encounter — you'll see a red squiggly and an error like "The value expression for the textbox uses an aggregate function, which is not allowed."
Here's where paginated reports really earn their name. With 4,000 invoices to generate, you don't want a single report with everything crammed together — you want each invoice on its own page (or set of pages).
The simplest approach is the one we've already set up: a @OrderID parameter that filters the query to a single order. Users (or an automated system) request specific invoices by passing the parameter.
When you publish to the Power BI service, the service creates a URL parameter structure like:
https://app.powerbi.com/groups/{workspace-id}/rdlreports/{report-id}?rp:OrderID=71774
This can be scripted or integrated into other systems — your billing system generates a PDF link for each customer with their specific OrderID embedded.
For a batch scenario — generate all invoices in one export — remove the WHERE clause filter and instead use a List data region to create one invoice per order.
A List is a free-form repeating container. You set its dataset, and it renders everything inside it once per row (or once per group). For invoices, you'd group the List by SalesOrderID.
Insert a List from the Insert menu. Right-click on the list and select Add Group > Parent Group, grouping by =Fields!SalesOrderID.Value.
Then drag all your invoice layout elements (the Bill-To block, the line items table, the totals section) inside the List. The entire invoice layout will repeat for each unique SalesOrderID.
To force a page break between invoices:
Now each invoice starts on a fresh page in the PDF output.
Pro Tip: The Keep Together property on tables and rectangles prevents a data region from breaking across pages if it fits. Set this on smaller data regions where mid-element page breaks would look wrong. For large tables that legitimately span pages, you want the header row to repeat — right-click the table header row, select Row Visibility, and set Repeat header rows on each page.
When generating batch invoices, you might want page numbers that reset per invoice (Page 1 of 2 within Invoice #71774, then Page 1 of 1 for Invoice #71775) rather than global page numbering.
Use these expressions:
= "Page " & Globals!PageNumber & " of " & Globals!TotalPages
This gives global page numbers. For group-level reset page numbering, you need to set ResetPageNumber = true on the group's page break settings and use PageNumberInGroup instead — but this is an advanced scenario that often requires the Tablix Member properties in the XML. For most invoice scenarios, global numbering in the page footer is sufficient.
An invoice is relatively simple because it's one order. An account statement is more complex: it shows all transactions for a customer within a date range, grouped by month, with running balances. This tests your understanding of grouping and aggregation in paginated reports.
SELECT
c.CustomerID,
c.FirstName + ' ' + c.LastName AS CustomerName,
c.EmailAddress,
soh.SalesOrderID,
soh.OrderDate,
soh.TotalDue,
DATENAME(MONTH, soh.OrderDate) + ' ' + CAST(YEAR(soh.OrderDate) AS VARCHAR) AS MonthYear,
YEAR(soh.OrderDate) * 100 + MONTH(soh.OrderDate) AS MonthSort
FROM SalesLT.Customer c
JOIN SalesLT.SalesOrderHeader soh ON c.CustomerID = soh.CustomerID
WHERE c.CustomerID = @CustomerID
AND soh.OrderDate BETWEEN @StartDate AND @EndDate
ORDER BY soh.OrderDate
Add three parameters: @CustomerID, @StartDate, and @EndDate.
Insert a Table. In the detail row, set up columns for Date, Order Number, and Amount.
Right-click the detail row's leftmost cell and select Insert Row > Group > Parent Group. Group by =Fields!MonthYear.Value. Use =Fields!MonthSort.Value as the sort expression.
This creates a parent group row above the detail rows. In the group row's cell that spans the full width, add:
=Fields!MonthYear.Value
Style this group header row with a slightly darker background than the alternating detail rows — something like #BDD7EE works well.
Add a group footer row (right-click the group, Add Footer). In the footer, show the monthly total:
= "Monthly Total: " & Format(Sum(Fields!TotalDue.Value), "C2")
Add a table footer for the statement total:
= "Statement Total: " & Format(Sum(Fields!TotalDue.Value), "C2")
The same Sum() function scoped to the table footer automatically aggregates across all groups, while the group footer Sum() scopes to the current group. Scoping in paginated report aggregates works by context — this is the same mental model as CALCULATE in DAX, just with different syntax.
A running balance — showing the cumulative total as of each order — uses the RunningValue function:
=Format(RunningValue(Fields!TotalDue.Value, Sum, Nothing), "C2")
RunningValue takes the field, the aggregate function, and the scope. Nothing means the running total spans the entire dataset.
Not every paginated report is a customer-facing document. A significant use case is operational reports — dense, information-rich tabular outputs that operations teams use for review, exception management, and record-keeping.
Think of a daily orders report that a warehouse manager prints every morning: all orders due to ship that day, grouped by shipping method, with priority flagging for late orders.
In operational reports, color-coding exceptions is essential. Set the Background Color of a row's cells using expressions:
= SWITCH(
Fields!DueDate.Value < Today() AND Fields!Status.Value <> 5, "#FFD7D7",
Fields!DueDate.Value = Today(), "#FFF3CD",
True, "White"
)
SWITCH in RDL works like a series of IIF statements — evaluate conditions in order, return the first match. This turns overdue rows red and same-day rows amber.
For bold text on high-priority items:
= IIF(Fields!DueDate.Value <= Today() AND Fields!Status.Value <> 5, "Bold", "Normal")
Operational reports often need to serve multiple audiences. A manager wants the cost columns; a picker doesn't need them. Use parameters to toggle column visibility.
Add a Boolean parameter named @ShowCostColumns with allowed values True and False.
On each cost column header and detail cell, set the Hidden property:
= NOT Parameters!ShowCostColumns.Value
When users run the report, they choose whether to include cost data. The columns physically don't render when hidden — they don't take up space in the PDF output, unlike CSS display:none which can leave gaps.
Sometimes operational reports have many columns and need to render in Landscape orientation. Set this in page properties:
11in, Page Height: 8.5in (swap the dimensions for landscape)To fit a table exactly to the page width: select all columns in the table, right-click and select Distribute Columns Evenly, then manually adjust individual column widths so the total matches your body width (11in minus margins = 10in for landscape with 0.5in margins).
Warning: Never rely on the table auto-sizing. Always explicitly set column widths to sum to your available body width. Paginated reports do not reflow to fit content — if your table is 10.2in wide and your body is 10in, the last 0.2 inches will clip in PDF output with no warning during design time.
Once your report is designed and tested in Report Builder, publishing to the Power BI service is straightforward.
Go to File > Publish to Power BI Service. Sign in with your organizational account. Select the workspace (must be Premium or PPU). The report uploads as a .rdl file.
After publishing, go to the Power BI service and find your report. Open the dataset settings to configure data source credentials — this is the step people most commonly forget, and it causes the report to fail with a data source error on first run.
In the Power BI service, paginated reports can be exported to:
For invoice and statement automation, PDF is almost always the right choice.
The Power BI service REST API supports programmatic export of paginated reports. Combine this with Power Automate to trigger batch invoice generation automatically:
OrderID) and specify PDF as the formatThis pattern supports fully automated distribution: your system generates all invoices overnight and emails them to customers before business opens.
Work through this complete exercise to build a functional invoice report from scratch.
Scenario: You work for a wholesale distributor. Finance needs an invoice template that can be triggered for any sales order and exported to PDF for email distribution.
Step 1: Set Up
Step 2: Connect to Data
@OrderID parameter is created automaticallyStep 3: Design the Header Section
Step 4: Bill-To Block
#F0F4F8)Step 5: Line Items Table
Step 6: Totals Block
Step 7: Page Footer
= "Page " & Globals!PageNumber & " of " & Globals!TotalPagesStep 8: Test
Step 9: Publish
"Aggregate function not allowed in this context"
You're referencing a dataset field directly in a textbox outside a data region without wrapping it in an aggregate like First(), Max(), or Sum(). Even if every row has the same value (like a customer name repeated across all detail rows), the expression engine requires an explicit aggregate. Use First(Fields!CustomerName.Value) in body textboxes outside your table.
PDF output clips content on the right side Your total report width (including margins) exceeds the page size. Calculate: if your page is 8.5in and margins are 0.5in each, your body must be exactly 7.5in wide. In practice, even 0.01in of overflow causes clipping. Select everything, check the rightmost element's X + Width, and trim if necessary. Use the Size and Position dialog (right-click > Size and Position) for precise adjustments.
Blank pages appearing between every page This is the #1 paginated report frustration. It happens when the report body width exceeds the available width, causing the renderer to create an extra blank column of pages. Fix it by ensuring your body content (including all data regions) is no wider than Page Width minus both horizontal margins.
Images not rendering in the published service External image URLs work in Report Builder preview but fail in the service due to security restrictions. Embed images directly in the report (set the image source to Embedded) or reference images from a publicly accessible URL. For company logos, embedding is always more reliable.
Parameters don't prompt in the service If your parameters have default values set, the service runs the report immediately without prompting. Remove the default value if you want the user to always enter one, or keep defaults for operational reports that should auto-run with sensible values.
Data source fails after publishing Report Builder authenticates with your Windows credentials. The service needs to re-authenticate using stored credentials. Go to the workspace, find the dataset settings for your report, expand Data Source Credentials, and enter the appropriate credentials. For SQL Server, use Basic authentication with a service account.
Running totals reset unexpectedly
RunningValue scope must match your intent. Nothing resets at the report level. If you're inside a grouped table and want reset per group, use the group name as the scope: RunningValue(Fields!Amount.Value, Sum, "GroupName"). Get the group name from the Row Groups pane — it defaults to Group1, Group2, etc., but you should rename groups to something meaningful.
Conditional hiding leaves blank space
When you hide a textbox, it hides the textbox but the space remains if the textbox is positioned absolutely. To eliminate the space, the textbox must be inside a container where the CanGrow/CanShrink properties propagate up — or use a table row with Hidden set on the row, not just the cell.
You've now covered the complete lifecycle of a production paginated report: understanding the physical page rendering model, connecting to data sources with parameterized queries, building invoice layouts with precise positioning, using expressions for dynamic content and conditional formatting, controlling page breaks for batch output, grouping data for statements, and publishing and automating export.
The skills you've built here transfer directly to any paginated reporting scenario — customer-facing or internal, simple or complex. The core patterns repeat: data regions bound to datasets, expressions that make everything dynamic, and page properties that govern physical output.
Where to go from here:
.rdl file in any text editor.The investment in learning paginated reports pays off every time someone asks for a "printable" report and you can deliver something that actually looks professional, renders correctly, and scales to thousands of documents without manual work.
Getting Started with Power BI