How to Add Control Breaks and Subtotals to an Interactive Report in Oracle APEX

Group an Interactive Report into sections, sort each section, add subtotals and a highlight, save it as the primary report, and check it against ROLLUP.

A control break splits a report into sections, one for each value of a column. Add an aggregate such as Sum, and every section ends with its own subtotal. Readers see each group's rows and total together, instead of scrolling a long list and adding numbers by hand.

The Interactive Report in Oracle APEX has both features in its Actions menu, with no SQL changes. A developer can build the layout once and save it as the primary default report, so every user opens the page with the sections, subtotals and highlights already in place.

The guide Oracle APEX Interactive Reports: The Complete Guide describes control breaks and aggregates in one short paragraph. This guide is the full task: the break, the sort inside each section, two subtotals, a highlight rule, saving it for all users, and checking every subtotal against SQL ROLLUP.

The example uses order lines grouped by customer. The same steps work for any table with a column to group by and numbers to add up.

Step 1: Create the Tables

Three tables hold the data:

  • WHOLESALE_CUSTOMERS has one row per customer with a unique name and a city.
  • WHOLESALE_ORDERS has one row per order with a unique order number, the customer, the order date and a status.
  • WHOLESALE_ORDER_LINES has the items of each order with quantity and unit price. LINE_AMOUNT is a virtual column, quantity times unit price.

Create the tables with three customers, seven orders and 16 order lines:

Example:

create table wholesale_customers (
  customer_id     number generated by default on null as identity,
  customer_name   varchar2(100)  not null,
  city            varchar2(50)   not null,
  constraint wholesale_customers_pk primary key (customer_id),
  constraint wholesale_customers_name_uk unique (customer_name)
);

create table wholesale_orders (
  order_id        number generated by default on null as identity,
  order_no        varchar2(20)   not null,
  customer_id     number         not null,
  order_date      date           not null,
  status          varchar2(10)   default on null 'NEW' not null,
  constraint wholesale_orders_pk primary key (order_id),
  constraint wholesale_orders_no_uk unique (order_no),
  constraint wholesale_orders_customer_fk foreign key (customer_id) references wholesale_customers (customer_id),
  constraint wholesale_orders_status_ck check (status in ('NEW', 'SHIPPED', 'CANCELLED'))
);

create index wholesale_orders_customer_ix on wholesale_orders (customer_id);

create table wholesale_order_lines (
  line_id         number generated by default on null as identity,
  order_id        number         not null,
  line_no         number(3)      not null,
  item_name       varchar2(100)  not null,
  quantity        number(6)      not null,
  unit_price      number(10,2)   not null,
  line_amount     number(12,2)   generated always as (quantity * unit_price) virtual,
  constraint wholesale_order_lines_pk primary key (line_id),
  constraint wholesale_order_lines_order_fk foreign key (order_id) references wholesale_orders (order_id) on delete cascade,
  constraint wholesale_order_lines_no_uk unique (order_id, line_no),
  constraint wholesale_order_lines_qty_ck check (quantity > 0),
  constraint wholesale_order_lines_price_ck check (unit_price >= 0)
);

insert into wholesale_customers (customer_name, city) values
  ('Aster Retail', 'Pune'),
  ('Banyan Grocers', 'Chennai'),
  ('Coral Hotels', 'Goa');

insert into wholesale_orders (order_no, customer_id, order_date, status)
select v.order_no, c.customer_id, v.order_date, v.status
from wholesale_customers c
join (values
  ('SO-1001', 'Aster Retail',   date '2026-09-02', 'SHIPPED'),
  ('SO-1002', 'Aster Retail',   date '2026-09-18', 'NEW'),
  ('SO-1003', 'Banyan Grocers', date '2026-09-05', 'SHIPPED'),
  ('SO-1004', 'Banyan Grocers', date '2026-09-21', 'SHIPPED'),
  ('SO-1005', 'Banyan Grocers', date '2026-10-03', 'NEW'),
  ('SO-1006', 'Coral Hotels',   date '2026-09-11', 'SHIPPED'),
  ('SO-1007', 'Coral Hotels',   date '2026-10-08', 'NEW')
) v (order_no, customer_name, order_date, status)
  on v.customer_name = c.customer_name;

insert into wholesale_order_lines (order_id, line_no, item_name, quantity, unit_price)
select o.order_id, v.line_no, v.item_name, v.quantity, v.unit_price
from wholesale_orders o
join (values
  ('SO-1001', 1, 'Basmati Rice 25 kg',  10,  1850),
  ('SO-1001', 2, 'Sunflower Oil 15 L',   6,  2100),
  ('SO-1001', 3, 'Sugar 50 kg',          4,  2300),
  ('SO-1002', 1, 'Toor Dal 30 kg',       5,  3600),
  ('SO-1002', 2, 'Tea Leaves 5 kg',      8,  1450),
  ('SO-1003', 1, 'Wheat Flour 50 kg',   12,  1900),
  ('SO-1003', 2, 'Sugar 50 kg',          6,  2300),
  ('SO-1004', 1, 'Sunflower Oil 15 L',  10,  2100),
  ('SO-1004', 2, 'Basmati Rice 25 kg',   8,  1850),
  ('SO-1004', 3, 'Salt 25 kg',          20,   300),
  ('SO-1005', 1, 'Toor Dal 30 kg',       3,  3600),
  ('SO-1006', 1, 'Coffee Beans 5 kg',    6,  4200),
  ('SO-1006', 2, 'Tea Leaves 5 kg',     10,  1450),
  ('SO-1006', 3, 'Sugar 50 kg',          2,  2300),
  ('SO-1007', 1, 'Basmati Rice 25 kg',  15,  1850),
  ('SO-1007', 2, 'Coffee Beans 5 kg',    4,  4200)
) v (order_no, line_no, item_name, quantity, unit_price)
  on v.order_no = o.order_no;

commit;

select c.customer_name, o.order_no, o.status, count(l.line_id) as lines, sum(l.line_amount) as order_amount
from wholesale_customers c
join wholesale_orders o on o.customer_id = c.customer_id
join wholesale_order_lines l on l.order_id = o.order_id
group by c.customer_name, o.order_no, o.status
order by c.customer_name, o.order_no;

Output:

Table WHOLESALE_CUSTOMERS created.

Table WHOLESALE_ORDERS created.

Index WHOLESALE_ORDERS_CUSTOMER_IX created.

Table WHOLESALE_ORDER_LINES created.

3 rows inserted.

7 rows inserted.

16 rows inserted.

Commit complete.

CUSTOMER_NAME     ORDER_NO    STATUS        LINES    ORDER_AMOUNT
_________________ ___________ __________ ________ _______________
Aster Retail      SO-1001     SHIPPED           3           40300
Aster Retail      SO-1002     NEW               2           29600
Banyan Grocers    SO-1003     SHIPPED           2           36600
Banyan Grocers    SO-1004     SHIPPED           3           41800
Banyan Grocers    SO-1005     NEW               1           10800
Coral Hotels      SO-1006     SHIPPED           3           44300
Coral Hotels      SO-1007     NEW               2           44550

7 rows selected.
  • WHOLESALE_ORDERS_CUSTOMER_FK links each order to an existing customer, and WHOLESALE_ORDERS_CUSTOMER_IX indexes that foreign key.
  • WHOLESALE_ORDER_LINES_ORDER_FK links each line to its order. ON DELETE CASCADE removes the lines when an order is deleted.
  • WHOLESALE_ORDER_LINES_NO_UK allows each line number only once per order. Its index also serves the foreign key on ORDER_ID.
  • LINE_AMOUNT is declared as a virtual column, so the database always calculates it. No trigger or page process is needed.
  • The check constraints allow only known statuses, quantities above zero and prices of zero or more.

Step 2: Get the Expected Subtotals with SQL

Before building the report, get the numbers it must show. GROUP BY ROLLUP adds a subtotal row per customer and a grand total row:

Example:

select case grouping_id(c.customer_name, o.order_no)
         when 0 then c.customer_name
         when 1 then c.customer_name || ' subtotal'
         else 'Grand total'
       end as customer_name,
       o.order_no,
       sum(l.quantity) as quantity,
       sum(l.line_amount) as line_amount
from wholesale_customers c
join wholesale_orders o on o.customer_id = c.customer_id
join wholesale_order_lines l on l.order_id = o.order_id
group by rollup (c.customer_name, o.order_no)
order by c.customer_name nulls last, o.order_no nulls last;

Output:

CUSTOMER_NAME              ORDER_NO       QUANTITY    LINE_AMOUNT
__________________________ ___________ ___________ ______________
Aster Retail               SO-1001              20          40300
Aster Retail               SO-1002              13          29600
Aster Retail subtotal                           33          69900
Banyan Grocers             SO-1003              18          36600
Banyan Grocers             SO-1004              38          41800
Banyan Grocers             SO-1005               3          10800
Banyan Grocers subtotal                         59          89200
Coral Hotels               SO-1006              18          44300
Coral Hotels               SO-1007              19          44550
Coral Hotels subtotal                           37          88850
Grand total                                    129         247950

11 rows selected.

The subtotal rows are the numbers each report section must end with: 69,900 for Aster Retail, 89,200 for Banyan Grocers and 88,850 for Coral Hotels. The order rows are used later for a break on two columns.

Step 3: Create the Interactive Report Page

The report shows one row per order line, with the customer and order details joined in. The source query is:

Example:

select l.line_id,
       c.customer_name,
       o.order_no,
       o.order_date,
       o.status,
       l.line_no,
       l.item_name,
       l.quantity,
       l.unit_price,
       l.line_amount
from wholesale_order_lines l
join wholesale_orders o on o.order_id = l.order_id
join wholesale_customers c on c.customer_id = o.customer_id

In Page Designer, click Create (the plus icon) and choose Page. Select the Interactive Report tile:

Create a Page wizard with the Interactive Report tile marked with a red box
Choose the Interactive Report page type.

On the Create Interactive Report screen, enter Page Number 800 and Name Order Lines Breakdown. Leave Page Mode Normal and Include Form Page off:

Create Interactive Report screen with Page Number 800 and Name Order Lines Breakdown marked with a red box
Page number and name.

Under Data Source, keep Local Database, set Source Type to SQL Query and paste the query above into the editor:

Data Source section with Source Type SQL Query and the report query in the editor marked with a red box
The report source is a SQL query.

Under Navigation, keep the defaults: a breadcrumb and a new navigation menu entry with the icon fa-table. Click Create Page:

Navigation section with Use Breadcrumb on, Use Navigation on, Create a new entry and Icon fa-table
The wizard adds the page to the navigation menu.

Step 4: Prepare the Columns in Page Designer

Page Designer opens page 800. Select the Order Lines Breakdown region and open the Attributes tab. Under Actions Menu, Control Break, Highlight, Aggregate and Save Report are on by default. These switches decide what users find in the Actions menu, so leave them on:

Attributes tab of the Order Lines Breakdown region with the Control Break, Highlight, Aggregate and Save Report switches marked with red boxes
The Actions menu features this guide uses.

Expand Columns and select LINE_ID. Set Type to Hidden. The column is the row key, and readers do not need to see it:

LINE_ID column in Page Designer with Type Hidden marked with a red box
Hide the line ID.

Select UNIT_PRICE and set Format Mask to 999G999G990 under Appearance:

UNIT_PRICE column in Page Designer with Format Mask 999G999G990 marked with a red box
Format the unit price.

Select LINE_AMOUNT and set the same Format Mask. The subtotal of a column uses the column's format mask, so the sums get thousands separators too:

LINE_AMOUNT column in Page Designer with Format Mask 999G999G990 marked with a red box
Format the line amount.

Click Save and run the page. The report lists all 16 order lines:

Order Lines Breakdown page listing order lines with Customer Name, Order No, Order Date, Status, Line No, Item Name, Quantity, Unit Price and Line Amount
The report before any control break.

Step 5: Add a Control Break on the Customer

Sign in to the application as a developer, so you can save the result for all users in Step 9. Open page 800, click Actions, then Format, then Control Break:

Actions menu with Format open and Control Break marked with a red box
Control Break is under Actions, Format.

In the Control Break dialog, set Column 1 to Customer Name and keep Status Enabled:

Control Break dialog with Column 1 Customer Name and Status Enabled marked with a red box
Break the report on Customer Name.

Click Apply. The report now has a section per customer, with a heading such as "Customer Name: Aster Retail". The Customer Name column moves from the table into the heading. A Customer Name entry with a checkbox appears above the report:

Report split into sections by customer, with rows inside each section in no fixed order
One section per customer.

Look at the rows inside each section. In this test, Aster Retail started with line 2 of SO-1001, and SO-1005 came before the last line of SO-1004. The break sorts by customer, but the rows inside a section have no fixed order yet.

Step 6: Sort the Rows Inside Each Section

Click Actions, then Data, then Sort:

Actions menu with Data open and Sort marked with a red box
Sort is under Actions, Data.

Set Column 1 to Order No and Column 2 to Line No, both Ascending:

Sort dialog with Order No ascending and Line No ascending marked with a red box
Sort by order number, then line number.

Click Apply. Each section now lists its orders in number order and each order's lines from 1 upward. The control break column stays the first sort, so the sections keep their customer order.

Step 7: Add the Subtotals

Click Actions, then Data, then Aggregate:

Actions menu with Data open and Aggregate marked with a red box
Aggregate is under Actions, Data.

Keep Aggregation as New Aggregation. Set Function to Sum and Column to Line Amount:

Aggregate dialog with Function Sum and Column Line Amount marked with a red box
Sum of Line Amount.

Click Apply. Open Actions, Data, Aggregate again and add a second aggregate, Sum of Quantity:

Aggregate dialog with Function Sum and Column Quantity marked with a red box
Sum of Quantity.

Click Apply. Each section now ends with a row that shows the total quantity and the total amount for that customer. For Sum, the Column list offered only the number columns: Line No, Quantity, Unit Price and Line Amount.

Step 8: Highlight Large Lines

A highlight rule colors rows or cells that meet a condition. Click Actions, then Format, then Highlight:

Actions menu with Format open and Highlight marked with a red box
Highlight is under Actions, Format.

Fill in the Highlight dialog:

  • Name: Large line. Highlight Type: Cell. Enabled: on.
  • Background Color: #ffd6d6.
  • Highlight Condition: Column Line Amount, Operator >=, Expression 20000.
Highlight dialog with Name Large line, Highlight Type Cell, Background Color #ffd6d6 and the condition Line Amount greater than or equal to 20000 marked with a red box
Color line amounts of 20,000 or more.

Click Apply. Cell highlights color only the Line Amount cell. Choose Row instead to color the whole row.

Step 9: Save the Layout as the Primary Default Report

So far, only your session sees the sections and subtotals. To give them to every user, click Actions, then Report, then Save Report:

Actions menu with Report open and Save Report marked with a red box
Save Report is under Actions, Report.

A developer session shows an extra list at the top of the Save Report dialog, Save (Only displayed for developers). Choose As Default Report Settings in that list:

Save Report dialog with the Save list for developers marked with a red box
Choose As Default Report Settings in this list.

The Save Default Report dialog opens. Choose Primary and click Apply:

Save Default Report dialog with Default Report Type Primary selected and marked with a red box
Replace the primary report with this layout.

Primary replaces the report every user sees first. Alternative would keep the plain list as the first report and add this layout as a second, named report.

Back in Page Designer, the region's Saved Reports node now has a Primary Default report:

Page Designer with the Primary Default saved report selected and Visibility Primary Default marked with a red box
The saved report is part of the application.

Because the default report is stored in the application, it moves with the application export to test and production. Query the APEX dictionary views to see what was saved:

Example:

select report_type, application_user, break_on, sum_columns_on_break,
       sort_column_1, sort_column_2, display_rows
from apex_application_page_ir_rpt
where application_id = 400
and page_id = 800
and report_type = 'PRIMARY_DEFAULT';

select c.condition_name, c.condition_column_name, c.condition_operator,
       c.condition_expression, c.highlight_cell_color
from apex_application_page_ir_cond c
join apex_application_page_ir_rpt r on r.report_id = c.report_id
where r.application_id = 400
and r.page_id = 800
and r.report_type = 'PRIMARY_DEFAULT';

Output:

REPORT_TYPE        APPLICATION_USER    BREAK_ON         SUM_COLUMNS_ON_BREAK    SORT_COLUMN_1    SORT_COLUMN_2       DISPLAY_ROWS
__________________ ___________________ ________________ _______________________ ________________ ________________ _______________
PRIMARY_DEFAULT    APXWS_DEFAULT       CUSTOMER_NAME    LINE_AMOUNT:QUANTITY    ORDER_NO         LINE_NO                       50

CONDITION_NAME    CONDITION_COLUMN_NAME    CONDITION_OPERATOR    CONDITION_EXPRESSION    HIGHLIGHT_CELL_COLOR
_________________ ________________________ _____________________ _______________________ _______________________
Large line        LINE_AMOUNT              >=                    20000                   #ffd6d6

BREAK_ON holds the break column, SUM_COLUMNS_ON_BREAK the two summed columns, and the two sort columns follow. The second query shows the highlight rule.

Step 10: Test the Report as Another User

Sign in as SALES_USER, a user with the Reader role who has never opened the page. The report opens with the control break, the sort, the subtotals and the highlighted cells:

Order Lines Breakdown page for sales_user with sections per customer, sorted rows, subtotals and highlighted line amounts
Another user gets the saved layout.

Scroll down to the last section. The subtotal rows read 33 and 69,900 for Aster Retail, 59 and 89,200 for Banyan Grocers, and 37 and 88,850 for Coral Hotels:

Lower part of the report with the Banyan Grocers and Coral Hotels sections and their subtotal rows
Every section ends with its subtotals.

These are the same numbers as the ROLLUP subtotal rows in Step 2, for both the quantity and the amount.

The report has no grand total row while the control break is on. In this test, APEX showed only the section subtotals. To see the grand total, clear the checkbox next to Customer Name above the report. The break is switched off, and the last row shows 129 and 247,950, the ROLLUP grand total:

Report with the Customer Name control break checkbox cleared and marked with a red box, showing all lines and a grand total row of 129 and 247,950
With the break off, the aggregates show the grand total.

Tick the checkbox again to bring the sections back. Users can also change the report, for example remove the highlight. Actions, Report, Reset returns them to the saved primary report, not to the plain list. In this test, the report after Reset was identical to the saved layout.

Step 11: Break on Two Columns

A control break can use up to six columns. Open Actions, Format, Control Break again and set Column 2 to Order No:

Control Break dialog with Column 1 Customer Name and Column 2 Order No, the second row marked with a red box
Add Order No as the second break column.

Click Apply. The two columns do not create nested sections with a subtotal on each level. They form one combined section per customer and order, such as "Customer Name: Aster Retail, Order No: SO-1001":

Report with one section per customer and order, each ending with its own subtotal
One section per customer and order.

Each section ends with the order total: 40,300 for SO-1001, 29,600 for SO-1002 and so on. These match the order rows of the ROLLUP query. For subtotals per customer, use the single break from Step 5.

Things to Know

  • A control break sorts the sections by the break column, but not the rows inside them. Add a sort on the columns that should order each section.
  • Aggregates use the column's format mask, so set it in Page Designer first.
  • With a control break on, the report showed section subtotals only and no grand total. Clearing the break checkbox showed the grand total.
  • Several break columns form one combined section, not nested levels with their own subtotals.
  • Only a developer session shows As Default Report Settings. Primary changes what every user sees first, and Reset returns users to it.

Related Guides

Conclusion

An Interactive Report in Oracle APEX can show grouped sections with subtotals without any SQL: break on a column, sort the rows inside each section, add Sum aggregates and a highlight rule, then save the layout as the primary default report.

Every user then opens the page with the sections and subtotals in place. In this example, every subtotal and the grand total matched SQL ROLLUP exactly.

Vinish Kapoor
Vinish Kapoor

An Oracle ACE, author of four books on Oracle APEX, SQL and PL/SQL, and Oracle Forms, and a software developer building Oracle database applications since 2001.

guest

0 Comments
Oldest
Newest Most Voted