Skip to main content
RapidDev - Software Development Agency
outsystems-tutorial

Aggregates vs SQL Queries: When to Use Each in OutSystems

Use Aggregates for all standard SELECT queries — they are visual, DB-agnostic, and auto-optimized. Switch to Advanced SQL only when you need subqueries, UNION/INTERSECT, bulk UPDATE/DELETE, window functions, or database-specific features that Aggregates cannot express. Advanced SQL uses {Entity}.[Attribute] reference syntax and requires manual parameter binding.

What you'll learn

  • Identify the five scenarios where Advanced SQL is required instead of Aggregates
  • Write Advanced SQL queries using OutSystems entity and attribute reference syntax
  • Bind input parameters and map output structures in an Advanced SQL node
  • Understand the SQL syntax differences between O11 (SQL Server) and ODC (PostgreSQL)
  • Use Advanced SQL for bulk operations that Aggregate-based CRUD cannot perform efficiently
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate11 min read30-40 minOutSystems 11 and ODCMarch 2026RapidDev Engineering Team
TL;DR

Use Aggregates for all standard SELECT queries — they are visual, DB-agnostic, and auto-optimized. Switch to Advanced SQL only when you need subqueries, UNION/INTERSECT, bulk UPDATE/DELETE, window functions, or database-specific features that Aggregates cannot express. Advanced SQL uses {Entity}.[Attribute] reference syntax and requires manual parameter binding.

The Decision Framework: Aggregate First, SQL When Needed

OutSystems documentation states it clearly: use Aggregates whenever possible. Aggregates are database-agnostic (the same Aggregate works on SQL Server and PostgreSQL), auto-optimized (only fetches used columns), TrueChange-validated (breaks on rename), and visually self-documenting.

Advanced SQL is an escape hatch for capabilities outside the Aggregate visual builder. It is locked to the specific database platform — an Advanced SQL query written for O11 (SQL Server T-SQL) may need rewriting for ODC (PostgreSQL). This page covers when to cross that boundary and how to do it correctly.

Prerequisites

  • Solid understanding of OutSystems Aggregates (see outsystems-aggregate-tutorial)
  • Basic familiarity with SQL SELECT syntax
  • Access to a Server Action in Service Studio (Advanced SQL lives in Server Actions)

Step-by-step guide

1

Identify when Aggregates are insufficient — the five trigger scenarios

Use Advanced SQL when your query requires ANY of these: 1. SUBQUERIES: `SELECT * FROM Employee WHERE DepartmentId IN (SELECT Id FROM Department WHERE Budget > 100000)` 2. UNION / INTERSECT / EXCEPT: combining results from two separate queries 3. Bulk UPDATE or DELETE: `UPDATE Employee SET IsActive = 0 WHERE DepartmentId = @DeptId` — updating 10,000 rows at once instead of looping through them 4. Window functions (SQL Server: ROW_NUMBER() OVER, RANK() OVER, LEAD/LAG) — not available in Aggregates 5. HAVING clause: filter by aggregate results (e.g., `HAVING COUNT(*) > 5`) — Aggregate Filters tab maps to WHERE, not HAVING If your query does NOT require these, use an Aggregate. Complex multi-join queries with many filters can still be expressed as Aggregates.

Expected result: You can clearly categorize your query need into Aggregate or Advanced SQL based on whether it requires subqueries, UNION, bulk DML, window functions, or HAVING.

2

Add an Advanced SQL node to a Server Action

Advanced SQL is only available inside Server Actions — NOT in Client Actions, Screen Aggregates, or Preparation actions. In Service Studio: Logic tab → Server Actions → right-click → Add Server Action. Name it 'GetHighBudgetDeptEmployees'. In the action flow editor, drag an SQL element from the Toolbox (under Data section) into the flow. The SQL editor opens. The SQL editor has: - Query text area: write your SQL - Parameters tab: define @ParameterName inputs - Output Structure tab: define the return record structure - Test tab: test with literal values

Expected result: An SQL node appears in the Server Action flow between Start and End. Double-clicking opens the SQL editor with Query, Parameters, Output Structure, and Test tabs.

3

Write the query using OutSystems entity reference syntax

In the SQL editor, write your query using OutSystems' entity reference syntax: `{EntityName}` — refers to the entity's database table `{EntityName}.[AttributeName]` — refers to a specific column `@ParameterName` — binds an input parameter Example — find employees in departments with budget over a threshold: ```sql SELECT {Employee}.[Id], {Employee}.[FirstName], {Employee}.[LastName] FROM {Employee} WHERE {Employee}.[DepartmentId] IN ( SELECT {Department}.[Id] FROM {Department} WHERE {Department}.[Budget] > @MinBudget ) ORDER BY {Employee}.[LastName] ``` The curly brace syntax is mandatory — OutSystems translates `{Employee}` to the actual physical table name at compile time, ensuring your query still works after table renames.

typescript
1/* Advanced SQL Query — Subquery example */
2SELECT
3 {Employee}.[Id],
4 {Employee}.[FirstName],
5 {Employee}.[LastName],
6 {Employee}.[Email],
7 {Department}.[Name] AS DepartmentName
8FROM {Employee}
9INNER JOIN {Department} ON {Employee}.[DepartmentId] = {Department}.[Id]
10WHERE {Employee}.[DepartmentId] IN (
11 SELECT {Department}.[Id]
12 FROM {Department}
13 WHERE {Department}.[Budget] > @MinBudget
14 AND {Department}.[IsActive] = 1
15)
16ORDER BY {Department}.[Name], {Employee}.[LastName]

Expected result: Query text is entered in the SQL editor using {Entity}.[Attribute] syntax. TrueChange validates that entity and attribute names in curly braces exist in your data model.

4

Define input parameters and the output structure

In the SQL Parameters tab: - Click Add → Name: MinBudget, Data Type: Decimal, Expand Inline: No - 'Expand Inline' should be No for scalar values (prevents SQL injection). Only use Yes for dynamic column names or ORDER BY clauses — with caution. In the Output Structure tab: - Click Add Record → select an existing Structure, or click Add Attribute to define the output columns manually - Match the column names and types to what your SELECT returns: - Id: Long Integer - FirstName: Text - LastName: Text - Email: Email - DepartmentName: Text In the action flow, connect the SQL node's Output to a local variable: - Add a local variable of type `[YourSQL].List` or create a Structure matching your output - The SQL node returns a RecordList of your output structure

typescript
1/* SQL Parameters tab */
2Name: MinBudget Type: Decimal Expand Inline: No
3
4/* Output Structure (matches SELECT columns) */
5Attribute: Id Type: Long Integer
6Attribute: FirstName Type: Text
7Attribute: LastName Type: Text
8Attribute: Email Type: Email
9Attribute: DepartmentName Type: Text
10
11/* Action flow: accessing SQL output */
12GetHighBudgetDeptEmployees.List /* RecordList */
13GetHighBudgetDeptEmployees.List.Current.Id
14GetHighBudgetDeptEmployees.List.Current.DepartmentName
15GetHighBudgetDeptEmployees.Count /* total rows */
16GetHighBudgetDeptEmployees.List.Empty /* Boolean */

Expected result: SQL node has a MinBudget input parameter and a typed output structure. The action flow can access GetHighBudgetDeptEmployees.List and iterate through results.

5

Write a bulk UPDATE for mass data changes

Aggregates fetch data; they cannot update or delete data. For bulk operations — deactivating all employees in a department, updating prices by percentage — Advanced SQL is the only performant option. In a Server Action, add an SQL node. Write a bulk UPDATE: ```sql UPDATE {Employee} SET {Employee}.[IsActive] = 0 WHERE {Employee}.[DepartmentId] = @DepartmentId ``` For bulk UPDATE, the Output Structure should be left empty (UPDATE does not return rows). The SQL node's output is just a Count of affected rows. IMPORTANT: OutSystems wraps Server Actions in transactions by default. If this SQL node is in a Server Action that raises an exception, the UPDATE is rolled back automatically. NEVER write `UPDATE {Employee} SET IsActive = 0` without a WHERE clause in production — this would deactivate all employees.

typescript
1/* Bulk UPDATE — deactivate all employees in a department */
2UPDATE {Employee}
3SET {Employee}.[IsActive] = 0,
4 {Employee}.[UpdatedOn] = GETDATE() /* O11: SQL Server */
5/* ODC PostgreSQL equivalent: */
6/* {Employee}.[UpdatedOn] = NOW() */
7WHERE {Employee}.[DepartmentId] = @DepartmentId
8
9/* Parameters tab */
10Name: DepartmentId Type: Department Identifier Expand Inline: No
11
12/* Bulk DELETE (archive old records) */
13DELETE FROM {AuditLog}
14WHERE {AuditLog}.[CreatedOn] < @CutoffDate
15
16/* Bulk INSERT from SELECT (copy to archive) */
17INSERT INTO {EmployeeArchive} ({EmployeeArchive}.[FirstName], {EmployeeArchive}.[Email])
18SELECT {Employee}.[FirstName], {Employee}.[Email]
19FROM {Employee}
20WHERE {Employee}.[HireDate] < @CutoffDate

Expected result: SQL node executes the bulk UPDATE. Server Action returns without error. The affected rows count is available at GetSQL.Count (where GetSQL is the SQL node name).

6

Handle O11 vs ODC SQL syntax differences

O11 uses SQL Server (T-SQL). ODC uses Aurora PostgreSQL. Key syntax differences you must handle when writing Advanced SQL: | Operation | O11 (SQL Server) | ODC (PostgreSQL) | |---|---|---| | Current timestamp | GETDATE() | NOW() | | Limit rows | TOP 10 / SELECT TOP (@n) | LIMIT 10 / LIMIT @n | | String concat | + operator | || operator or CONCAT() | | Cast to text | CAST(n AS NVARCHAR) | CAST(n AS TEXT) | | Boolean true | 1 | TRUE | | ISNULL | ISNULL(col, default) | COALESCE(col, default) | | Window functions | ROW_NUMBER() OVER (ORDER BY ...) | ROW_NUMBER() OVER (ORDER BY ...) — same | Best practice: avoid Advanced SQL altogether for cross-platform modules. When SQL is necessary, write two versions and use a Site Property (O11) or Setting (ODC) to switch between them — or use Aggregates wherever possible.

typescript
1/* O11 (SQL Server T-SQL) */
2SELECT TOP (@MaxRows)
3 {Employee}.[Id],
4 {Employee}.[FirstName] + ' ' + {Employee}.[LastName] AS FullName,
5 ISNULL({Department}.[Name], 'No Department') AS DepartmentName,
6 ROW_NUMBER() OVER (ORDER BY {Employee}.[LastName]) AS RowNum
7FROM {Employee}
8LEFT JOIN {Department} ON {Employee}.[DepartmentId] = {Department}.[Id]
9WHERE {Employee}.[IsActive] = 1
10ORDER BY {Employee}.[LastName]
11
12/* ODC (PostgreSQL) */
13SELECT
14 {Employee}.[Id],
15 {Employee}.[FirstName] || ' ' || {Employee}.[LastName] AS FullName,
16 COALESCE({Department}.[Name], 'No Department') AS DepartmentName,
17 ROW_NUMBER() OVER (ORDER BY {Employee}.[LastName]) AS RowNum
18FROM {Employee}
19LEFT JOIN {Department} ON {Employee}.[DepartmentId] = {Department}.[Id]
20WHERE {Employee}.[IsActive] = TRUE
21ORDER BY {Employee}.[LastName]
22LIMIT @MaxRows

Expected result: You have a working query for both O11 and ODC. For modules targeting both platforms, the OutSystems SQL node generates the correct dialect automatically when using {Entity}.[Attribute] syntax — but GETDATE() vs NOW() and TOP vs LIMIT must be handled manually.

Complete working example

AdvancedSQL_Reference.outsystems-sql
1/* =====================================================
2 Example 1: Subquery employees in high-budget depts
3 O11 (SQL Server) syntax
4 ===================================================== */
5SELECT
6 {Employee}.[Id],
7 {Employee}.[FirstName],
8 {Employee}.[LastName],
9 {Department}.[Name] AS DeptName
10FROM {Employee}
11INNER JOIN {Department} ON {Employee}.[DepartmentId] = {Department}.[Id]
12WHERE {Employee}.[DepartmentId] IN (
13 SELECT {Department}.[Id]
14 FROM {Department}
15 WHERE {Department}.[Budget] > @MinBudget
16)
17ORDER BY {Department}.[Name], {Employee}.[LastName]
18
19/* Parameters: MinBudget (Decimal, Expand Inline: No) */
20
21/* =====================================================
22 Example 2: UNION active and recently-departed employees
23 ===================================================== */
24SELECT {Employee}.[Id], {Employee}.[FirstName], {Employee}.[LastName], 'Active' AS Status
25FROM {Employee}
26WHERE {Employee}.[IsActive] = 1
27
28UNION ALL
29
30SELECT {EmployeeArchive}.[OriginalId], {EmployeeArchive}.[FirstName], {EmployeeArchive}.[LastName], 'Former' AS Status
31FROM {EmployeeArchive}
32WHERE {EmployeeArchive}.[DepartureDate] >= @CutoffDate
33ORDER BY LastName
34
35/* =====================================================
36 Example 3: HAVING departments with > N employees
37 ===================================================== */
38SELECT
39 {Department}.[Id],
40 {Department}.[Name],
41 COUNT({Employee}.[Id]) AS EmpCount
42FROM {Department}
43INNER JOIN {Employee} ON {Employee}.[DepartmentId] = {Department}.[Id]
44WHERE {Employee}.[IsActive] = 1
45GROUP BY {Department}.[Id], {Department}.[Name]
46HAVING COUNT({Employee}.[Id]) > @MinHeadcount
47ORDER BY EmpCount DESC
48
49/* =====================================================
50 Example 4: Bulk UPDATE
51 ===================================================== */
52UPDATE {Employee}
53SET {Employee}.[IsActive] = 0
54WHERE {Employee}.[DepartmentId] = @DeptId
55
56/* =====================================================
57 Example 5: Window function (ranking)
58 ===================================================== */
59SELECT
60 {Employee}.[Id],
61 {Employee}.[FirstName],
62 {Employee}.[Salary],
63 RANK() OVER (PARTITION BY {Employee}.[DepartmentId] ORDER BY {Employee}.[Salary] DESC) AS SalaryRank
64FROM {Employee}
65WHERE {Employee}.[IsActive] = 1

Common mistakes

Why it's a problem: Using hardcoded table names in Advanced SQL instead of {Entity}.[Attribute] syntax

How to avoid: Never write `SELECT * FROM OSSYS_Employee` — always write `SELECT {Employee}.[Id] FROM {Employee}`. Hardcoded physical table names break when the entity is renamed, when the module is deployed to a different schema prefix, or when migrating between O11 and ODC. The {Entity} syntax is validated by TrueChange and automatically translates to the correct physical name.

Why it's a problem: Setting Expand Inline = Yes for user-provided parameters

How to avoid: Expand Inline = Yes concatenates the parameter value directly into the SQL string — this creates a SQL injection vulnerability if the parameter contains user input. Always use Expand Inline = No for scalar values. Only use Expand Inline = Yes for developer-controlled values like dynamic column names or ORDER BY direction that cannot use parameterized binding.

Why it's a problem: Writing Advanced SQL in a Client Action instead of a Server Action

How to avoid: The SQL node (Advanced SQL) is only available in Server Actions and Preparation actions (Traditional Web). It cannot be added to Client Actions. Move your SQL query to a Server Action, then call the Server Action from your Client Action.

Why it's a problem: Using O11 SQL Server syntax (GETDATE(), TOP, +) in an ODC module

How to avoid: ODC uses Aurora PostgreSQL. GETDATE() becomes NOW(), TOP n becomes LIMIT n, string concatenation (+) becomes || or CONCAT(). When writing Advanced SQL for ODC, use PostgreSQL syntax. The {Entity}.[Attribute] syntax works on both platforms, but SQL built-in functions and clauses are platform-specific.

Best practices

  • Default to Aggregates and only use Advanced SQL for the five specific scenarios — Aggregates are safer, cross-platform, and TrueChange-validated (rename propagation). Advanced SQL breaks silently when entity names change if you don't use the {Entity}.[Attribute] syntax.
  • Always use {Entity}.[Attribute] syntax in Advanced SQL queries — never hardcode table names. If an entity is renamed or the module is deployed to a different physical schema, the curly brace syntax ensures queries still work.
  • Never set Expand Inline = Yes for user-provided parameters — this constructs the SQL string by interpolation and creates SQL injection vulnerabilities. Expand Inline = Yes is only safe for developer-controlled values like dynamic column names or ORDER BY direction.
  • Test Advanced SQL with the Test tab in the SQL editor before publishing — this runs the query live against your development database and shows results and errors before you deploy.
  • For bulk UPDATE and DELETE, add a confirmation step or test query before the SQL node — run a SELECT COUNT(*) first to show the user how many rows will be affected and confirm before proceeding.
  • Document the database platform assumption at the top of each Advanced SQL query with a comment (/* O11 SQL Server */ or /* ODC PostgreSQL */) — this prevents future developers from accidentally using wrong syntax.

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I'm building an OutSystems O11 Server Action. I need to find all employees in departments where the department budget exceeds a certain threshold, using a subquery. Write the Advanced SQL query using OutSystems {Entity}.[Attribute] syntax, define the input parameters (with Expand Inline = No), and describe the Output Structure I need to define. Also explain when I should use an Aggregate instead.

OutSystems Prompt

In my OutSystems O11 module, I need to deactivate all employees in a specific department using a bulk UPDATE (not a For Each loop). Write the Advanced SQL query using {Employee}.[IsActive] syntax, define the DepartmentId input parameter, and describe where this SQL node goes in the Server Action flow.

Frequently asked questions

Can I use stored procedures in OutSystems Advanced SQL?

You can call stored procedures using EXEC syntax in O11 (SQL Server): EXEC dbo.MyStoredProcedure @Param1 = @MyParam. However, this is strongly discouraged in OutSystems because stored procedures bypass TrueChange validation, are not portable to ODC, and break the platform's change tracking. Use Advanced SQL for ad-hoc queries and move complex logic to Server Actions.

How do I return multiple result sets from a single Advanced SQL node?

OutSystems Advanced SQL nodes return a single result set. If your query produces multiple result sets (multiple SELECT statements separated by semicolons), only the last result set is returned. For multiple result sets, use multiple SQL nodes in the same Server Action, each with its own output structure.

Does OutSystems protect against SQL injection when using Advanced SQL?

Yes, when you use parameterized parameters with Expand Inline = No. Parameters bound with Expand Inline = No are passed as SQL parameters (prepared statements), which prevent injection. The only risk is when Expand Inline = Yes is used for user-controlled values — this performs string concatenation and MUST NOT be used with user input.

Why does my Advanced SQL query work in Test but fail after publishing?

The most common cause is the Test tab using the development database with test data, while the published app connects to the production database with different data. Also check: (1) the output structure column names match exactly what your SELECT aliases return, (2) data type mismatches between SELECT output and the output structure definition, and (3) permission issues on the production database schema.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.