How to Show Actual vs Target in a Combination Chart in Oracle APEX

Build an Oracle APEX combination chart with actuals as green or red bars and the monthly target as a line, with filters and months without data shown as 0.

An actual vs target chart answers one question at a glance: are we on track? Bars show what really happened in each period, and a line shows the goal for the same period. Where a bar ends below the line, the period missed its target.

Oracle APEX draws this with a Combination chart, which mixes bar and line series in one region. This guide builds one with monthly targets, months without data shown as 0, bars colored green or red by result, a custom tooltip with the percentage of target, and filters for the year and the branch.

The guide Oracle APEX Charts: A Complete Guide touches on combination charts with a second y-axis for a different measure. This guide covers the actual vs target case in depth: both series share one axis, and it fixes three problems found while testing: an empty chart on the first visit, months sorted alphabetically, and a wrong legend color.

The example uses monthly revenue of two branches. The same steps work for any measure with a target, such as production units, support tickets or website visits.

Step 1: Create the Tables

The example needs three tables. BRANCHES holds the branches, BRANCH_TARGETS holds one target per branch and month, and BRANCH_REVENUE holds the individual revenue entries:

Example:

create table branches (
  branch_id    number generated by default on null as identity
               constraint branches_pk primary key,
  branch_name  varchar2(50) not null
               constraint branches_name_uk unique
);

create table branch_targets (
  target_id      number generated by default on null as identity
                 constraint branch_targets_pk primary key,
  branch_id      number not null
                 constraint branch_targets_branch_fk references branches,
  target_month   date not null
                 constraint branch_targets_month_ck check (target_month = trunc(target_month, 'MM')),
  target_amount  number(12,2) not null
                 constraint branch_targets_amount_ck check (target_amount >= 0),
  constraint branch_targets_uk unique (branch_id, target_month)
);

create table branch_revenue (
  revenue_id    number generated by default on null as identity
                constraint branch_revenue_pk primary key,
  branch_id     number not null
                constraint branch_revenue_branch_fk references branches,
  revenue_date  date not null,
  amount        number(12,2) not null
                constraint branch_revenue_amount_ck check (amount > 0)
);

create index branch_revenue_branch_date_ix on branch_revenue (branch_id, revenue_date);

insert into branches (branch_name)
values ('North'), ('South');

insert into branch_targets (branch_id, target_month, target_amount)
select b.branch_id, add_months(date '2026-01-01', m.n - 1), m.n * 1000 + case b.branch_name when 'North' then 40000 else 30000 end
from branches b
cross join (select level as n from dual connect by level <= 12) m;

insert into branch_revenue (branch_id, revenue_date, amount)
select b.branch_id, v.revenue_date, v.amount
from (values
  ('North', date '2026-01-09', 21000), ('North', date '2026-01-23', 22500),
  ('North', date '2026-02-06', 19000), ('North', date '2026-02-20', 20000),
  ('North', date '2026-03-06', 26000), ('North', date '2026-03-27', 24000),
  ('North', date '2026-05-08', 25000), ('North', date '2026-05-22', 23500),
  ('North', date '2026-06-05', 22000), ('North', date '2026-06-19', 21000),
  ('North', date '2026-07-10', 27000), ('North', date '2026-07-24', 25500),
  ('North', date '2026-08-07', 24000), ('North', date '2026-08-21', 26000),
  ('North', date '2026-09-04', 23000), ('North', date '2026-09-18', 22000),
  ('South', date '2026-01-16', 33000),
  ('South', date '2026-02-13', 30500),
  ('South', date '2026-03-13', 31000),
  ('South', date '2026-04-10', 36000),
  ('South', date '2026-05-15', 34500),
  ('South', date '2026-06-12', 33000),
  ('South', date '2026-07-17', 39000),
  ('South', date '2026-08-14', 36500),
  ('South', date '2026-09-11', 41000)
) v (branch_name, revenue_date, amount)
join branches b on b.branch_name = v.branch_name;

commit;

select b.branch_name,
       (select count(*) from branch_targets t where t.branch_id = b.branch_id) as target_months,
       (select count(*) from branch_revenue r where r.branch_id = b.branch_id) as revenue_rows,
       (select sum(r.amount) from branch_revenue r where r.branch_id = b.branch_id) as revenue_total
from branches b
order by b.branch_name;

Output:

Table BRANCHES created.

Table BRANCH_TARGETS created.

Table BRANCH_REVENUE created.

Index BRANCH_REVENUE_BRANCH_DATE_IX created.

2 rows inserted.

24 rows inserted.

25 rows inserted.

Commit complete.

BRANCH_NAME       TARGET_MONTHS    REVENUE_ROWS    REVENUE_TOTAL
______________ ________________ _______________ ________________
North                        12              16           371500
South                        12               9           314500

A few details matter for the chart:

  • The check constraint BRANCH_TARGETS_MONTH_CK only accepts the first day of a month, so every target joins cleanly to a month of revenue.
  • The unique constraint BRANCH_TARGETS_UK allows only one target per branch and month.
  • Each branch has 12 monthly targets for 2026. The sample revenue stops in September, and the North branch has no revenue in April.

Step 2: Create a View with Monthly Actuals and Targets

The chart needs one row per branch and month with both numbers. This view sums the revenue per month and joins it to the targets:

Example:

create or replace view branch_monthly_v as
select t.branch_id,
       t.target_month,
       t.target_amount,
       nvl(r.actual_amount, 0) as actual_amount
from branch_targets t
left join (
  select branch_id,
         trunc(revenue_date, 'MM') as revenue_month,
         sum(amount) as actual_amount
  from branch_revenue
  group by branch_id, trunc(revenue_date, 'MM')
) r on r.branch_id = t.branch_id
   and r.revenue_month = t.target_month;

select b.branch_name, to_char(v.target_month, 'Mon') as month, v.target_amount, v.actual_amount
from branch_monthly_v v
join branches b on b.branch_id = v.branch_id
where v.target_month between date '2026-03-01' and date '2026-05-01'
order by b.branch_name, v.target_month;

Output:

View BRANCH_MONTHLY_V created.

BRANCH_NAME    MONTH       TARGET_AMOUNT    ACTUAL_AMOUNT
______________ ________ ________________ ________________
North          Mar                 43000            50000
North          Apr                 44000                0
North          May                 45000            48500
South          Mar                 33000            31000
South          Apr                 34000            36000
South          May                 35000            34500

6 rows selected.

The view starts from BRANCH_TARGETS and uses a left join, so the target table decides which months exist. A month without revenue still gets a row, and NVL turns its missing total into 0. North in April shows exactly that.

Step 3: Create a Blank Page

In App Builder, open the application and click Create Page. On the Component tab, select Blank Page:

Create a Page wizard with the Blank Page tile marked with a red box
Choose the Blank Page type.

Set Page Number to 1010 and Name to Actual vs Target. Turn Use Breadcrumb off, keep Use Navigation on, set Icon to fa-bar-chart and click Create Page:

Create Blank Page screen with Page Number 1010, Name Actual vs Target, Use Breadcrumb off, Use Navigation on and Icon fa-bar-chart marked with red boxes
The page number, name and navigation entry.

APEX opens the new page in Page Designer.

Step 4: Add the Year and Branch Filters

In the Rendering tree, right-click Body and choose Create Region. Set Name to Filters and keep the Type Static Content:

Page Designer with the region Name Filters marked with a red box
A region for the two filter items.

Right-click the Filters region and choose Create Page Item. Set Name to P1010_YEAR, Type to Select List and Label to Year. Under Settings, set Page Action on Selection to Submit Page, so the page reloads when the user picks a year:

Page item P1010_YEAR with Name, Type Select List, Label Year and Page Action on Selection Submit Page marked with red boxes
The year item submits the page on change.

Under List of Values, set Type to SQL Query and enter this query. It lists every year that has targets. Turn Display Null Value off, because the chart always needs a year:

Example:

select distinct to_char(target_month, 'YYYY') as d,
       to_char(target_month, 'YYYY') as r
from branch_targets
order by 1 desc
List of Values of P1010_YEAR with Type SQL Query, the query and Display Null Value off marked with red boxes
The years come from the target table.

Under Default, set Type to Expression, Language to PL/SQL and PL/SQL Expression to to_char(sysdate, 'YYYY'). The page then opens on the current year:

Default group of P1010_YEAR with Type Expression, Language PL/SQL and the expression to_char(sysdate, 'YYYY') marked with a red box
The current year is the default.

Create a second item in the Filters region in the same way. Set Name to P1010_BRANCH_ID, Type to Select List, Label to Branch and Page Action on Selection to Submit Page:

Page item P1010_BRANCH_ID with Name, Type Select List, Label Branch and Page Action on Selection Submit Page marked with red boxes
The branch item.

Under List of Values, set Type to SQL Query and enter the query below. Keep Display Null Value on and set Null Display Value to - All Branches -. An empty value will mean all branches together:

Example:

select branch_name as d,
       branch_id as r
from branches
order by branch_name
List of Values of P1010_BRANCH_ID with Type SQL Query, the query and Null Display Value - All Branches - marked with red boxes
The null entry stands for all branches.

Under Layout, turn Start New Row off, so both filters sit side by side:

Layout group of P1010_BRANCH_ID with Start New Row off marked with a red box
The branch item shares the row with the year item.

Step 5: Add the Combination Chart Region

Right-click the Filters region and choose Create Region Below. Set Name to Actual vs Target and Type to Chart:

Page Designer with the region Name Actual vs Target and Type Chart marked with a red box
The chart region below the filters.

APEX creates a bar chart with one series named New and the axes x and y. Click the Attributes tab and set Type under Chart to Combination. Under Settings, set Time Axis Type to Enabled:

Attributes tab of the chart region with Chart Type Combination and Time Axis Type Enabled marked with red boxes
A combination chart with a time axis.

The time axis is needed for the month order. In a first test, the series used month names such as Jan and Feb as the label. With two series, APEX sorted the months by Sort Order Label - Ascending, and the x-axis showed Apr, Aug, Dec, Feb, Jan and so on.

The Sort Order list only offers value or label sorting, not the query order. With a DATE column as the label and the time axis enabled, the months appear in calendar order. The axis also prints them as short month names.

Step 6: Create the Actual Bar Series

Expand Series under the chart region and select New. Set Name to Actual and keep the Type Bar. Under Source, set Type to SQL Query and enter this query:

Example:

select target_month,
       month_label,
       target_amount,
       actual_amount,
       case when actual_amount >= target_amount then '#2e7d32'
            when actual_amount < target_amount then '#c62828'
       end as bar_color,
       case when actual_amount is not null then
         'Actual: ' || to_char(actual_amount, 'FM999G999G990') ||
         ', Target: ' || to_char(target_amount, 'FM999G999G990') ||
         ', ' || to_char(round(100 * actual_amount / nullif(target_amount, 0), 1), 'FM9990.0') || '% of target'
       end as tooltip
from (
  select to_char(target_month, 'Mon') as month_label,
         target_month,
         sum(target_amount) as target_amount,
         case when target_month <= trunc(sysdate, 'MM') then sum(actual_amount) end as actual_amount
  from branch_monthly_v
  where extract(year from target_month) = to_number(:P1010_YEAR default null on conversion error)
    and (:P1010_BRANCH_ID is null or branch_id = to_number(:P1010_BRANCH_ID default null on conversion error))
  group by target_month
)
order by target_month
Series Actual with Name, Type Bar, Source Type SQL Query and the series query marked with red boxes
The bar series reads the monthly view.

The inner query adds up the branches for each month, either for the selected branch or for all of them. The outer query adds the columns for the chart:

  • TARGET_MONTH is a DATE and becomes the label on the time axis.
  • ACTUAL_AMOUNT is NULL for months that have not started yet, so the chart draws no bar for them. Past months without revenue keep the 0 from the view.
  • BAR_COLOR is green when the actual reached the target and red when it did not.
  • TOOLTIP holds the text shown when the mouse is over a bar.
  • to_number with DEFAULT NULL ON CONVERSION ERROR protects the query from item values that are not numbers. Step 10 tests this.

Run the same query in SQL with the bind variable P1010_YEAR set to 2026 and an empty P1010_BRANCH_ID. These are the label, value and color columns the chart will get:

Output:

PL/SQL procedure successfully completed.

MONTH_LABEL       TARGET_AMOUNT    ACTUAL_AMOUNT BAR_COLOR
______________ ________________ ________________ ____________
Jan                       72000            76500 #2e7d32
Feb                       74000            69500 #c62828
Mar                       76000            81000 #2e7d32
Apr                       78000            36000 #c62828
May                       80000            83000 #2e7d32
Jun                       82000            76000 #c62828
Jul                       84000            91500 #2e7d32
Aug                       86000            86500 #2e7d32
Sep                       88000            86000 #c62828
Oct                       90000                0 #c62828
Nov                       92000
Dec                       94000

12 rows selected.

The output was taken in October 2026, so October shows 0 and November and December stay empty.

Next, set Page Items to Submit to P1010_YEAR,P1010_BRANCH_ID:

Source group of the Actual series with Page Items to Submit P1010_YEAR,P1010_BRANCH_ID marked with a red box
The chart sends both filter values with its data request.

Do not skip this setting. In a first test without it, the chart showed No data to display on the first visit. The year item showed 2026 from its default, but the default value was not yet in session state when the chart loaded its data with a separate request.

Only after the user changed a filter and the page was submitted did the chart show bars. With Page Items to Submit, the chart sends the current values of both items with every data request.

Under Column Mapping, set Label to TARGET_MONTH, Value to ACTUAL_AMOUNT and Custom Tooltip to TOOLTIP. Under Appearance, set Color to &BAR_COLOR. including the final period:

Column Mapping of the Actual series with Label TARGET_MONTH, Value ACTUAL_AMOUNT, Custom Tooltip TOOLTIP and Appearance Color &BAR_COLOR. marked with red boxes
The label, value, tooltip and color columns of the bars.

The &BAR_COLOR. substitution gives every bar the color from its own row.

Step 7: Add the Target Line Series

Right-click Series and choose Create Series. Set Name to Target and Type to Line. Under Source, set Type to SQL Query and enter the same query as for the Actual series. Set Page Items to Submit to P1010_YEAR,P1010_BRANCH_ID as well:

Series Target with Name, Type Line, Source Type SQL Query and the series query marked with red boxes
The line series uses the same query.

Under Column Mapping, set Label to TARGET_MONTH and Value to TARGET_AMOUNT. Under Appearance, set Color to #37474f. Under Line, set Style to Dashed and Width to 3:

Target series with Label TARGET_MONTH, Value TARGET_AMOUNT, Color #37474f, Line Style Dashed and Width 3 marked with red boxes
A dark dashed line for the target.

Both series use TARGET_MONTH as the label, so each target point sits above the bar of the same month. Leave Assigned To Y2 Axis off. Actual and target use the same unit, and only one shared axis makes the bar and the line directly comparable.

Step 8: Title the Value Axis

Expand Axes and select y. Set Title to Revenue:

Axis y with Title Revenue marked with a red box
The y-axis title.

Step 9: Fix the Legend Color

Run the page at this point and look at the legend. The Actual entry showed a red square, although most bars were green. APEX sets the color of the whole series from the &BAR_COLOR. substitution of one row, and the legend uses that color.

Select the chart region, click the Attributes tab and enter this function in Initialization JavaScript Function under Advanced:

Example:

function( options ) {
    options.dataFilter = function( data ) {
        if ( data.series && data.series.length ) {
            data.series[0].color = "#2e7d32";
        }
        return data;
    };
    return options;
}
Advanced group of the chart attributes with the Initialization JavaScript Function marked with a red box
The function sets the series color before the chart is drawn.

The dataFilter function runs each time the chart receives data. It sets the color of the first series, Actual, to green. The color of each bar still comes from its own row, so only the legend changes.

The IF check is needed. In a first version without it, a filter with no matching rows raised the error "Cannot set properties of undefined" in the browser, because the data then contains no series.

Click Save.

Step 10: Test the Chart

Run the page. It opens with the current year and all branches. The bars show the actual revenue per month, green or red, and the dashed line shows the target:

Actual vs Target chart for 2026 and all branches with green and red bars and a dashed target line
All branches together on the first visit.

The bars match the SQL output of Step 6: January 76,500 against 72,000 in green, April 36,000 against 78,000 in red. October has the value 0, so no bar is visible, and November and December have only target points.

Select North in the Branch list. The page submits and the chart shows one branch:

Actual vs Target chart for the North branch with the Branch select list marked with a red box
The chart for the North branch.

April has no bar, because North has no revenue in April. The view returned 0 for the month instead of leaving it out, so the target point for April is still drawn and the gap is easy to see.

Move the mouse over the July bar. The custom tooltip shows the actual, the target and the percentage:

Chart with the tooltip Actual: 52,500, Target: 47,000, 111.7% of target over the July bar
July beat its target.

A red bar shows the same details for a missed month:

Chart with the tooltip Actual: 39,000, Target: 42,000, 92.9% of target over the February bar
February missed its target.

Now compare the chart with a query that reads the base tables directly, without the view:

Example:

select to_char(t.target_month, 'Mon') as month,
       t.target_amount,
       (select nvl(sum(r.amount), 0)
        from branch_revenue r
        where r.branch_id = t.branch_id
          and r.revenue_date >= t.target_month
          and r.revenue_date < add_months(t.target_month, 1)) as actual_amount
from branch_targets t
join branches b on b.branch_id = t.branch_id
where b.branch_name = 'North'
  and t.target_month < date '2026-10-01'
order by t.target_month;

Output:

MONTH       TARGET_AMOUNT    ACTUAL_AMOUNT
________ ________________ ________________
Jan                 41000            43500
Feb                 42000            39000
Mar                 43000            50000
Apr                 44000                0
May                 45000            48500
Jun                 46000            43000
Jul                 47000            52500
Aug                 48000            50000
Sep                 49000            45000

9 rows selected.

Every month matches the bars and points in the North chart, including the 0 for April.

Last, try a forged filter value. Signed in as a user with read-only access, a value was added to the Branch list from the browser console and the page was submitted with branch values abc, 999 and 1 or 1=1. APEX accepted the submitted values, and the chart showed No data to display each time:

Actual vs Target page with the forged branch value abc and the message No data to display
A forged branch value only returns an empty chart.

The values are bind variables, so the text 1 or 1=1 is never run as SQL. Without to_number and DEFAULT NULL ON CONVERSION ERROR, a value such as abc breaks the query instead:

Example:

variable P1010_BRANCH_ID varchar2(20)
exec :P1010_BRANCH_ID := 'abc'
select count(*) from branch_monthly_v where branch_id = :P1010_BRANCH_ID;
select count(*) from branch_monthly_v where branch_id = to_number(:P1010_BRANCH_ID default null on conversion error);

Output:

PL/SQL procedure successfully completed.

Error starting at line : 3 File @ b104/50-forged.sql
In command -
select count(*) from branch_monthly_v where branch_id = :P1010_BRANCH_ID
Error at Command Line : 3 Column : 58 File @ b104/50-forged.sql
Error report -
SQL Error: ORA-01722: unable to convert string value containing 'a' to a number:
ORA-03302: (ORA-01722 details) invalid string value: abc
Help: https://docs.oracle.com/error-help/db/ora-01722/

   COUNT(*)
___________
          0

Things to Know

  • Set Page Items to Submit on every series that uses page items. Item defaults are not in session state when the chart requests its data, and the chart stays empty.
  • With more than one series, APEX sorts the x-axis by Sort Order. Use a DATE label with Time Axis Type Enabled for months, or a label that sorts correctly as text.
  • Let the target table drive the months and left join the actuals. A month without data then shows 0 instead of disappearing. A month without a target, however, is missing from the chart.
  • A bar with the value 0 is not visible and shows no tooltip. The target point of that month is still drawn.
  • Return NULL for future months, so the chart does not show them as missed targets.
  • A color substitution such as &BAR_COLOR. colors each bar, but the legend takes one row's color. Set the series color for the legend with a dataFilter function.
  • Keep actual and target on the same y-axis. A second y-axis is for a different measure, such as a count next to an amount.

Related Guides

Conclusion

An actual vs target chart in Oracle APEX is a Combination chart with a bar series for the actuals and a line series for the targets. A view that starts from the targets gives every month a row, so months without data appear as 0 instead of vanishing.

The details make the chart reliable: Page Items to Submit for the filters, a time axis for the month order, a color column for green and red bars, and a small dataFilter function for the legend. Users then see at a glance which months met the target and which did not.

Get more Vinish.dev tutorials on Google

See my articles more often when you search Google.

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